diff --git a/.github/workflows/check-configuration-files.yml b/.github/workflows/check-configuration-files.yml index f280e326c2..561b7d0019 100644 --- a/.github/workflows/check-configuration-files.yml +++ b/.github/workflows/check-configuration-files.yml @@ -32,14 +32,14 @@ on: jobs: build: name: Check thingsboard.yml file - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Checkout code uses: actions/checkout@v2 - - name: Set up Python 3.10 + - name: Set up Python 3.13 uses: actions/setup-python@v3 with: - python-version: "3.10.2" + python-version: "3.13.2" architecture: "x64" env: AGENT_TOOLSDIRECTORY: /opt/hostedtoolcache diff --git a/README.md b/README.md index 6bce2db844..60f172d5a8 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ Collect and Visualize your IoT data in minutes by following this [guide](https:/ ## Support - - [Q&A forum](https://groups.google.com/forum/#!forum/thingsboard) - [Stackoverflow](http://stackoverflow.com/questions/tagged/thingsboard) ## Licenses diff --git a/application/pom.xml b/application/pom.xml index a9356cc14a..6ce77646f8 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -124,6 +124,10 @@ org.thingsboard.common edge-api + + org.thingsboard.common + edqs + org.thingsboard dao @@ -369,6 +373,10 @@ com.google.firebase firebase-admin + + org.rocksdb + rocksdbjni + diff --git a/application/src/main/data/json/demo/dashboards/thermostats.json b/application/src/main/data/json/demo/dashboards/thermostats.json index 43a11a8709..40866db9ef 100644 --- a/application/src/main/data/json/demo/dashboards/thermostats.json +++ b/application/src/main/data/json/demo/dashboards/thermostats.json @@ -17,7 +17,10 @@ "realtimeType": 1, "interval": 1000, "timewindowMs": 86400000, - "quickInterval": "CURRENT_DAY" + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideQuickInterval": false }, "history": { "historyType": 0, @@ -27,7 +30,11 @@ "startTimeMs": 1694085177686, "endTimeMs": 1694171577686 }, - "quickInterval": "CURRENT_DAY" + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideFixedInterval": false, + "hideQuickInterval": false }, "aggregation": { "type": "NONE", @@ -407,7 +414,10 @@ "realtimeType": 1, "interval": 1000, "timewindowMs": 60000, - "quickInterval": "CURRENT_DAY" + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideQuickInterval": false }, "history": { "historyType": 0, @@ -417,7 +427,11 @@ "startTimeMs": 1694085177686, "endTimeMs": 1694171577686 }, - "quickInterval": "CURRENT_DAY" + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideFixedInterval": false, + "hideQuickInterval": false }, "aggregation": { "type": "AVG", @@ -460,595 +474,132 @@ "id": "c4631f94-2db3-523b-4d09-2a1a0a75d93f", "typeFullFqn": "system.input_widgets.update_multiple_attributes" }, - "3da9a9a1-0b9a-2e1f-0dcb-0ff34a695abb": { - "type": "latest", - "sizeX": 13, - "sizeY": 6, + "eda8a397-0959-690c-405c-11e2c9b2bc7e": { + "typeFullFqn": "system.time_series_chart", + "type": "timeseries", + "sizeX": 8, + "sizeY": 5, "config": { "datasources": [ { "type": "entity", + "name": "", + "entityAliasId": "12ae98c7-1ea2-52cf-64d5-763e9d993547", "dataKeys": [ { "name": "temperature", "type": "timeseries", - "label": "temperature", - "color": "#2196f3", - "settings": {}, - "_hash": 0.1371919646686739, + "label": "Temperature", + "color": "#EF5350", + "settings": { + "yAxisId": "default", + "showInLegend": true, + "dataHiddenByDefault": false, + "type": "line", + "lineSettings": { + "showLine": true, + "step": false, + "stepType": "start", + "smooth": true, + "lineType": "solid", + "lineWidth": 2.5, + "showPoints": false, + "showPointLabel": false, + "pointLabelPosition": "top", + "pointLabelFont": { + "family": "Roboto", + "size": 11, + "sizeUnit": "px", + "style": "normal", + "weight": "400", + "lineHeight": "1" + }, + "pointLabelColor": "rgba(0, 0, 0, 0.76)", + "pointShape": "circle", + "pointSize": 14, + "fillAreaSettings": { + "type": "gradient", + "opacity": 0.4, + "gradient": { + "start": 60, + "end": 10 + } + } + }, + "barSettings": { + "showBorder": false, + "borderWidth": 2, + "borderRadius": 0, + "showLabel": false, + "labelPosition": "top", + "labelFont": { + "family": "Roboto", + "size": 11, + "sizeUnit": "px", + "style": "normal", + "weight": "400", + "lineHeight": "1" + }, + "labelColor": "rgba(0, 0, 0, 0.76)", + "backgroundSettings": { + "type": "none", + "opacity": 0.4, + "gradient": { + "start": 100, + "end": 0 + } + } + } + }, + "_hash": 0.5973804076994531, + "units": "°C", "decimals": 1, - "postFuncBody": "return value || \"\";" - }, - { - "name": "humidity", - "type": "timeseries", - "label": "humidity", - "color": "#4caf50", - "settings": {}, - "_hash": 0.043177186765847475, - "decimals": 0, - "postFuncBody": "return value || \"\";" - }, - { - "name": "longitude", - "type": "attribute", - "label": "longitude", - "color": "#f44336", - "settings": {}, - "_hash": 0.5548964320315584 - }, - { - "name": "latitude", - "type": "attribute", - "label": "latitude", - "color": "#ffc107", - "settings": {}, - "_hash": 0.1803778014971602 - }, - { - "name": "active", - "type": "attribute", - "label": "active", - "color": "#607d8b", - "settings": {}, - "_hash": 0.30926987994082844 + "aggregationType": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null } ], - "entityAliasId": "68a058e1-fdda-8482-715b-3ae4a488568e" - } - ], - "timewindow": { - "displayValue": "", - "selectedTab": 0, - "realtime": { - "realtimeType": 1, - "interval": 1000, - "timewindowMs": 60000, - "quickInterval": "CURRENT_DAY" - }, - "history": { - "historyType": 0, - "interval": 1000, - "timewindowMs": 60000, - "fixedTimewindow": { - "startTimeMs": 1694085177686, - "endTimeMs": 1694171577686 + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] }, - "quickInterval": "CURRENT_DAY" - }, - "aggregation": { - "type": "AVG", - "limit": 25000 - } - }, - "showTitle": false, - "backgroundColor": "#fff", - "color": "rgba(0, 0, 0, 0.87)", - "padding": "8px", - "settings": { - "fitMapBounds": true, - "latKeyName": "latitude", - "lngKeyName": "longitude", - "showLabel": true, - "label": "${entityName}", - "tooltipPattern": "${entityName}

Temperature: ${temperature:1} °C
Humidity: ${humidity:0} %

Thermostat details
", - "markerImageSize": 48, - "useColorFunction": false, - "markerImages": [ - "tb-image;/api/images/system/thermostats_dashboard_widget_thermostat_maps_marker_image_0.svg", - "tb-image;/api/images/system/thermostats_dashboard_widget_thermostat_maps_marker_image_1.svg" - ], - "useMarkerImageFunction": true, - "colorFunction": "\n", - "color": "#fe7569", - "mapProvider": "OpenStreetMap.HOT", - "showTooltip": true, - "autocloseTooltip": true, - "customProviderTileUrl": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - "defaultCenterPosition": [ - 0, - 0 - ], - "showTooltipAction": "click", - "polygonKeyName": "coordinates", - "polygonOpacity": 0.5, - "polygonStrokeOpacity": 1, - "polygonStrokeWeight": 1, - "zoomOnClick": true, - "showCoverageOnHover": true, - "animate": true, - "maxClusterRadius": 80, - "removeOutsideVisibleBounds": true, - "useLabelFunction": true, - "labelFunction": "var color;\nif(dsData[dsIndex].active !== \"true\"){\n color = 'rgb(255, 0, 0)';\n} else {\n color = 'rgb(39, 134, 34)';\n}\nreturn '' + \n '${entityLabel}' + \n ''", - "defaultZoomLevel": 14, - "markerImageFunction": "var res;\nif(dsData[dsIndex].active !== \"true\"){\n\tvar res = {\n\t url: images[0],\n\t size: 48\n\t}\n} else {\n var res = {\n\t url: images[1],\n\t size: 48\n\t}\n}\nreturn res;" - }, - "title": "Thermostat maps", - "dropShadow": true, - "enableFullscreen": false, - "titleStyle": { - "fontSize": "16px", - "fontWeight": 400 - }, - "useDashboardTimewindow": true, - "showLegend": false, - "widgetStyle": {}, - "actions": { - "headerButton": [], - "tooltipAction": [ - { - "id": "bef25673-b37a-8821-bc0f-5d6dd3680f24", - "name": "navigate_to_details", - "icon": "more_horiz", - "type": "openDashboardState", - "targetDashboardStateId": "chart", - "setEntityId": true - } - ] - }, - "showTitleIcon": false, - "titleIcon": null, - "iconColor": "rgba(0, 0, 0, 0.87)", - "iconSize": "24px", - "titleTooltip": "", - "displayTimewindow": true - }, - "id": "3da9a9a1-0b9a-2e1f-0dcb-0ff34a695abb", - "typeFullFqn": "system.maps_v2.openstreetmap" - }, - "00fb2742-ba1f-7e43-673f-d6c08b72ed06": { - "type": "latest", - "sizeX": 24, - "sizeY": 12, - "config": { - "datasources": [ - { - "type": "entity", - "dataKeys": [ - { - "name": "longitude", - "type": "attribute", - "label": "longitude", - "color": "#2196f3", - "settings": {}, - "_hash": 0.3640193654284214 - }, + "latestDataKeys": [ { - "name": "latitude", + "name": "temperatureAlarmThreshold", "type": "attribute", - "label": "latitude", + "label": "temperatureAlarmThreshold", "color": "#4caf50", - "settings": {}, - "_hash": 0.49020393887695923 - }, - { - "name": "temperature", - "type": "timeseries", - "label": "temperature", - "color": "#f44336", - "settings": {}, - "_hash": 0.5885892766009955, - "postFuncBody": "return value || \"\";" - }, - { - "name": "humidity", - "type": "timeseries", - "label": "humidity", - "color": "#ffc107", - "settings": {}, - "_hash": 0.21077893588180707, - "postFuncBody": "return value || \"\";" - }, - { - "name": "active", - "type": "attribute", - "label": "active", - "color": "#607d8b", - "settings": {}, - "_hash": 0.34722983638504346 + "settings": { + "__thresholdKey": true + }, + "_hash": 0.7120450032526351 } - ], - "entityAliasId": "68a058e1-fdda-8482-715b-3ae4a488568e" + ] } ], "timewindow": { - "displayValue": "", + "hideAggregation": false, + "hideAggInterval": false, + "hideTimezone": false, "selectedTab": 0, "realtime": { - "realtimeType": 1, - "interval": 1000, - "timewindowMs": 60000, - "quickInterval": "CURRENT_DAY" - }, - "history": { - "historyType": 0, - "interval": 1000, - "timewindowMs": 60000, - "fixedTimewindow": { - "startTimeMs": 1694085177686, - "endTimeMs": 1694171577686 - }, - "quickInterval": "CURRENT_DAY" + "realtimeType": 0, + "timewindowMs": 3600000, + "quickInterval": "CURRENT_DAY", + "interval": 30000 }, "aggregation": { "type": "AVG", "limit": 25000 - } + }, + "timezone": null }, - "showTitle": false, - "backgroundColor": "#fff", + "showTitle": true, + "backgroundColor": "rgba(0, 0, 0, 0)", "color": "rgba(0, 0, 0, 0.87)", - "padding": "8px", - "settings": { - "fitMapBounds": true, - "latKeyName": "latitude", - "lngKeyName": "longitude", - "showLabel": true, - "label": "${entityName}", - "tooltipPattern": "${entityName}

Temperature: ${temperature:1} °C
Humidity: ${humidity:0} %

Delete", - "markerImageSize": 34, - "useColorFunction": false, - "markerImages": [ - "tb-image;/api/images/system/thermostats_dashboard_widget_thermostat_maps_marker_image_0.svg", - "tb-image;/api/images/system/thermostats_dashboard_widget_thermostat_maps_marker_image_1.svg" - ], - "useMarkerImageFunction": true, - "color": "#fe7569", - "mapProvider": "OpenStreetMap.HOT", - "showTooltip": true, - "autocloseTooltip": true, - "defaultCenterPosition": "0,0", - "customProviderTileUrl": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - "showTooltipAction": "click", - "polygonKeyName": "coordinates", - "polygonOpacity": 0.5, - "polygonStrokeOpacity": 1, - "polygonStrokeWeight": 1, - "zoomOnClick": true, - "showCoverageOnHover": true, - "animate": true, - "maxClusterRadius": 80, - "removeOutsideVisibleBounds": true, - "defaultZoomLevel": 12, - "labelFunction": "var color;\nif(dsData[dsIndex].active !== \"true\"){\n color = 'rgb(255, 0, 0)';\n} else {\n color = 'rgb(39, 134, 34)';\n}\nreturn '' + \n '${entityLabel}' + \n ''", - "markerImageFunction": "var res;\nif(dsData[dsIndex].active !== \"true\"){\n\tvar res = {\n\t url: images[0],\n\t size: 48\n\t}\n} else {\n var res = {\n\t url: images[1],\n\t size: 48\n\t}\n}\nreturn res;", - "useLabelFunction": true, - "provider": "openstreet-map", - "draggableMarker": true - }, - "title": "New Markers Placement - OpenStreetMap", - "dropShadow": true, - "enableFullscreen": false, - "titleStyle": { - "fontSize": "16px", - "fontWeight": 400 - }, - "useDashboardTimewindow": true, - "showLegend": false, - "widgetStyle": {}, - "actions": { - "tooltipAction": [ - { - "name": "delete", - "icon": "more_horiz", - "type": "custom", - "customFunction": "var entityDatasource = widgetContext.mapInstance.datasources.filter(\n function(entity) {\n return entity.entityId === entityId.id\n });\n\nwidgetContext.mapInstance.saveMarkerLocation(entityDatasource[0], null, null).subscribe(function success() {\n widgetContext.updateAliases();\n});", - "id": "54c293c4-9ca6-e34f-dc6a-0271944c1c66" - } - ] - }, - "showTitleIcon": false, - "titleIcon": null, - "iconColor": "rgba(0, 0, 0, 0.87)", - "iconSize": "24px", - "titleTooltip": "", - "displayTimewindow": true - }, - "id": "00fb2742-ba1f-7e43-673f-d6c08b72ed06", - "typeFullFqn": "system.input_widgets.markers_placement_openstreetmap" - }, - "0a430429-9078-9ae6-2b67-e4a15a2bf8bf": { - "type": "latest", - "sizeX": 6, - "sizeY": 6, - "config": { - "datasources": [ - { - "type": "entity", - "dataKeys": [ - { - "name": "longitude", - "type": "attribute", - "label": "longitude", - "color": "#2196f3", - "settings": {}, - "_hash": 0.3640193654284214 - }, - { - "name": "latitude", - "type": "attribute", - "label": "latitude", - "color": "#4caf50", - "settings": {}, - "_hash": 0.49020393887695923 - }, - { - "name": "temperature", - "type": "timeseries", - "label": "temperature", - "color": "#f44336", - "settings": {}, - "_hash": 0.5885892766009955, - "postFuncBody": "return value || \"\";" - }, - { - "name": "humidity", - "type": "timeseries", - "label": "humidity", - "color": "#ffc107", - "settings": {}, - "_hash": 0.21077893588180707, - "postFuncBody": "return value || \"\";" - }, - { - "name": "active", - "type": "attribute", - "label": "active", - "color": "#607d8b", - "settings": {}, - "_hash": 0.34722983638504346 - } - ], - "entityAliasId": "12ae98c7-1ea2-52cf-64d5-763e9d993547" - } - ], - "timewindow": { - "displayValue": "", - "selectedTab": 0, - "realtime": { - "realtimeType": 1, - "interval": 1000, - "timewindowMs": 60000, - "quickInterval": "CURRENT_DAY" - }, - "history": { - "historyType": 0, - "interval": 1000, - "timewindowMs": 60000, - "fixedTimewindow": { - "startTimeMs": 1694085177686, - "endTimeMs": 1694171577686 - }, - "quickInterval": "CURRENT_DAY" - }, - "aggregation": { - "type": "AVG", - "limit": 25000 - } - }, - "showTitle": false, - "backgroundColor": "#fff", - "color": "rgba(0, 0, 0, 0.87)", - "padding": "8px", - "settings": { - "fitMapBounds": true, - "latKeyName": "latitude", - "lngKeyName": "longitude", - "showLabel": true, - "label": "${entityName}", - "tooltipPattern": "${entityName}

Temperature: ${temperature:1} °C
Humidity: ${humidity:0} %

Delete", - "markerImageSize": 34, - "useColorFunction": false, - "markerImages": [ - "tb-image;/api/images/system/thermostats_dashboard_widget_thermostat_maps_marker_image_0.svg", - "tb-image;/api/images/system/thermostats_dashboard_widget_thermostat_maps_marker_image_1.svg" - ], - "useMarkerImageFunction": true, - "color": "#fe7569", - "mapProvider": "OpenStreetMap.HOT", - "showTooltip": true, - "autocloseTooltip": true, - "defaultCenterPosition": "0,0", - "customProviderTileUrl": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - "showTooltipAction": "click", - "polygonKeyName": "coordinates", - "polygonOpacity": 0.5, - "polygonStrokeOpacity": 1, - "polygonStrokeWeight": 1, - "zoomOnClick": true, - "showCoverageOnHover": true, - "animate": true, - "maxClusterRadius": 80, - "removeOutsideVisibleBounds": true, - "defaultZoomLevel": 5, - "labelFunction": "var color;\nif(dsData[dsIndex].active !== \"true\"){\n color = 'rgb(255, 0, 0)';\n} else {\n color = 'rgb(39, 134, 34)';\n}\nreturn '' + \n '${entityLabel}' + \n ''", - "markerImageFunction": "var res;\nif(dsData[dsIndex].active !== \"true\"){\n\tvar res = {\n\t url: images[0],\n\t size: 48\n\t}\n} else {\n var res = {\n\t url: images[1],\n\t size: 48\n\t}\n}\nreturn res;", - "useLabelFunction": true, - "provider": "openstreet-map", - "draggableMarker": true, - "editablePolygon": true - }, - "title": "New Markers Placement - OpenStreetMap", - "dropShadow": true, - "enableFullscreen": false, - "titleStyle": { - "fontSize": "16px", - "fontWeight": 400 - }, - "useDashboardTimewindow": true, - "showLegend": false, - "widgetStyle": {}, - "actions": { - "tooltipAction": [ - { - "name": "delete", - "icon": "more_horiz", - "type": "custom", - "customFunction": "var entityDatasource = widgetContext.mapInstance.datasources.filter(\n function(entity) {\n return entity.entityId === entityId.id\n });\n\nwidgetContext.mapInstance.saveMarkerLocation(entityDatasource[0], null, null).subscribe(function success() {\n widgetContext.updateAliases();\n});", - "id": "54c293c4-9ca6-e34f-dc6a-0271944c1c66" - } - ] - }, - "showTitleIcon": false, - "titleIcon": null, - "iconColor": "rgba(0, 0, 0, 0.87)", - "iconSize": "24px", - "titleTooltip": "", - "displayTimewindow": true - }, - "id": "0a430429-9078-9ae6-2b67-e4a15a2bf8bf", - "typeFullFqn": "system.input_widgets.markers_placement_openstreetmap" - }, - "eda8a397-0959-690c-405c-11e2c9b2bc7e": { - "typeFullFqn": "system.time_series_chart", - "type": "timeseries", - "sizeX": 8, - "sizeY": 5, - "config": { - "datasources": [ - { - "type": "entity", - "name": "", - "entityAliasId": "12ae98c7-1ea2-52cf-64d5-763e9d993547", - "dataKeys": [ - { - "name": "temperature", - "type": "timeseries", - "label": "Temperature", - "color": "#EF5350", - "settings": { - "yAxisId": "default", - "showInLegend": true, - "dataHiddenByDefault": false, - "type": "line", - "lineSettings": { - "showLine": true, - "step": false, - "stepType": "start", - "smooth": true, - "lineType": "solid", - "lineWidth": 2.5, - "showPoints": false, - "showPointLabel": false, - "pointLabelPosition": "top", - "pointLabelFont": { - "family": "Roboto", - "size": 11, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "1" - }, - "pointLabelColor": "rgba(0, 0, 0, 0.76)", - "pointShape": "circle", - "pointSize": 14, - "fillAreaSettings": { - "type": "gradient", - "opacity": 0.4, - "gradient": { - "start": 60, - "end": 10 - } - } - }, - "barSettings": { - "showBorder": false, - "borderWidth": 2, - "borderRadius": 0, - "showLabel": false, - "labelPosition": "top", - "labelFont": { - "family": "Roboto", - "size": 11, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "1" - }, - "labelColor": "rgba(0, 0, 0, 0.76)", - "backgroundSettings": { - "type": "none", - "opacity": 0.4, - "gradient": { - "start": 100, - "end": 0 - } - } - } - }, - "_hash": 0.5973804076994531, - "units": "°C", - "decimals": 1, - "aggregationType": null, - "funcBody": null, - "usePostProcessing": null, - "postFuncBody": null - } - ], - "alarmFilterConfig": { - "statusList": [ - "ACTIVE" - ] - }, - "latestDataKeys": [ - { - "name": "temperatureAlarmThreshold", - "type": "attribute", - "label": "temperatureAlarmThreshold", - "color": "#4caf50", - "settings": { - "__thresholdKey": true - }, - "_hash": 0.7120450032526351 - } - ] - } - ], - "timewindow": { - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false, - "hideAggregation": false, - "hideAggInterval": false, - "hideTimezone": false, - "selectedTab": 0, - "realtime": { - "realtimeType": 0, - "timewindowMs": 3600000, - "quickInterval": "CURRENT_DAY", - "interval": 30000 - }, - "aggregation": { - "type": "AVG", - "limit": 25000 - }, - "timezone": null - }, - "showTitle": true, - "backgroundColor": "rgba(0, 0, 0, 0)", - "color": "rgba(0, 0, 0, 0.87)", - "padding": "0px", + "padding": "0px", "settings": { "showLegend": true, "legendConfig": { @@ -1419,9 +970,6 @@ } ], "timewindow": { - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false, "hideAggregation": false, "hideAggInterval": false, "hideTimezone": false, @@ -1705,17 +1253,793 @@ "row": 0, "col": 0, "id": "ac90f089-197f-b767-82c3-2668844265a2" - } - }, - "states": { - "default": { - "name": "Thermostats", - "root": true, - "layouts": { - "main": { - "widgets": { - "f33c746c-0dfc-c212-395b-b448c8a17209": { - "sizeX": 11, + }, + "5186d1e3-f076-e062-5129-e4dd8e4adfb0": { + "typeFullFqn": "system.map", + "type": "latest", + "sizeX": 8.5, + "sizeY": 6, + "config": { + "datasources": [], + "timewindow": { + "displayValue": "", + "selectedTab": 0, + "realtime": { + "realtimeType": 1, + "interval": 1000, + "timewindowMs": 60000, + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideQuickInterval": false + }, + "history": { + "historyType": 0, + "interval": 1000, + "timewindowMs": 60000, + "fixedTimewindow": { + "startTimeMs": 1741884755143, + "endTimeMs": 1741971155143 + }, + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideFixedInterval": false, + "hideQuickInterval": false + }, + "aggregation": { + "type": "AVG", + "limit": 25000 + } + }, + "showTitle": false, + "backgroundColor": "rgba(0, 0, 0, 0)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "0px", + "settings": { + "mapType": "geoMap", + "layers": [ + { + "label": "{i18n:widgets.maps.layer.roadmap}", + "provider": "openstreet", + "layerType": "OpenStreetMap.Mapnik" + }, + { + "label": "{i18n:widgets.maps.layer.satellite}", + "provider": "openstreet", + "layerType": "Esri.WorldImagery" + }, + { + "label": "{i18n:widgets.maps.layer.hybrid}", + "provider": "openstreet", + "layerType": "Esri.WorldImagery", + "referenceLayer": "openstreetmap_hybrid" + } + ], + "imageSource": null, + "markers": [ + { + "dsType": "entity", + "dsLabel": "", + "dsDeviceId": null, + "dsEntityAliasId": "68a058e1-fdda-8482-715b-3ae4a488568e", + "dsFilterId": null, + "additionalDataKeys": [ + { + "name": "temperature", + "type": "timeseries", + "label": "temperature", + "color": "#2196f3", + "settings": {}, + "_hash": 0.570889787682481, + "aggregationType": "NONE", + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return value || \"\";" + }, + { + "name": "humidity", + "type": "timeseries", + "label": "humidity", + "color": "#2196f3", + "settings": {}, + "_hash": 0.13597394595782442, + "aggregationType": "NONE", + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return value || \"\";" + }, + { + "name": "active", + "type": "attribute", + "label": "active", + "color": "#2196f3", + "settings": {}, + "_hash": 0.21080919932756603 + } + ], + "label": { + "show": true, + "type": "function", + "pattern": "${entityName}", + "patternFunction": "var color;\nif(data.active !== \"true\"){\n color = 'rgb(255, 0, 0)';\n} else {\n color = 'rgb(39, 134, 34)';\n}\nreturn '' + \n '${entityLabel}' + \n ''" + }, + "tooltip": { + "show": true, + "trigger": "click", + "autoclose": true, + "type": "pattern", + "pattern": "${entityName}

Temperature: ${temperature:1} °C
Humidity: ${humidity:0} %

Thermostat details
", + "offsetX": 0, + "offsetY": -1, + "patternFunction": null, + "tagActions": [ + { + "name": "navigate_to_details", + "type": "openDashboardState", + "targetDashboardStateId": "chart", + "setEntityId": true, + "stateEntityParamName": null, + "openRightLayout": false, + "openInSeparateDialog": false, + "openInPopover": false + } + ] + }, + "click": { + "type": "doNothing" + }, + "groups": null, + "edit": { + "enabledActions": [], + "attributeScope": "SERVER_SCOPE", + "snappable": false + }, + "xKey": { + "name": "latitude", + "label": "latitude", + "type": "attribute", + "settings": {}, + "color": "#2196f3" + }, + "yKey": { + "name": "longitude", + "label": "longitude", + "type": "attribute", + "settings": {}, + "color": "#2196f3" + }, + "markerType": "icon", + "markerShape": { + "shape": "markerShape1", + "size": 34, + "color": { + "type": "constant", + "color": "#307FE5" + } + }, + "markerIcon": { + "size": 48, + "color": { + "type": "function", + "color": "rgb(255, 0, 0)", + "range": null, + "colorFunction": "if (data.active !== \"true\"){\n return 'rgb(255, 0, 0)';\n} else {\n return 'rgb(39, 134, 34)';\n}" + }, + "iconContainer": "iconContainer1", + "icon": "thermostat" + }, + "markerImage": { + "type": "function", + "imageFunction": "return {};", + "images": [] + }, + "markerOffsetX": 0.5, + "markerOffsetY": 1, + "markerClustering": { + "enable": false, + "zoomOnClick": true, + "maxZoom": null, + "maxClusterRadius": 80, + "zoomAnimation": true, + "showCoverageOnHover": true, + "spiderfyOnMaxZoom": false, + "chunkedLoad": false, + "lazyLoad": true, + "useClusterMarkerColorFunction": false, + "clusterMarkerColorFunction": null + } + } + ], + "polygons": [], + "circles": [], + "additionalDataSources": [], + "controlsPosition": "topleft", + "zoomActions": [ + "scroll", + "doubleClick", + "controlButtons" + ], + "scales": [], + "dragModeButton": false, + "fitMapBounds": true, + "useDefaultCenterPosition": false, + "defaultCenterPosition": "0,0", + "defaultZoomLevel": 14, + "mapPageSize": 16384, + "mapActionButtons": [], + "background": { + "type": "color", + "color": "#fff", + "overlay": { + "enabled": false, + "color": "rgba(255,255,255,0.72)", + "blur": 3 + } + }, + "padding": "8px" + }, + "title": "Map", + "useDashboardTimewindow": true, + "displayTimewindow": true, + "showTitleIcon": false, + "titleTooltip": "", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": {}, + "widgetCss": "", + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "pageSize": 1024, + "noDataDisplayMessage": "", + "configMode": "advanced", + "titleFont": null, + "titleColor": null, + "margin": "0px", + "borderRadius": "0px", + "iconSize": "24px", + "titleIcon": "map", + "iconColor": "#1F6BDD", + "actions": {} + }, + "row": 0, + "col": 0, + "id": "5186d1e3-f076-e062-5129-e4dd8e4adfb0" + }, + "1bbd4b8d-db42-ed7c-70c2-3d32c771843d": { + "typeFullFqn": "system.map", + "type": "latest", + "sizeX": 8.5, + "sizeY": 6, + "config": { + "datasources": [], + "timewindow": { + "displayValue": "", + "selectedTab": 0, + "realtime": { + "realtimeType": 1, + "interval": 1000, + "timewindowMs": 60000, + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideQuickInterval": false + }, + "history": { + "historyType": 0, + "interval": 1000, + "timewindowMs": 60000, + "fixedTimewindow": { + "startTimeMs": 1741884755143, + "endTimeMs": 1741971155143 + }, + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideFixedInterval": false, + "hideQuickInterval": false + }, + "aggregation": { + "type": "AVG", + "limit": 25000 + } + }, + "showTitle": false, + "backgroundColor": "rgba(0, 0, 0, 0)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "0px", + "settings": { + "mapType": "geoMap", + "layers": [ + { + "label": "{i18n:widgets.maps.layer.roadmap}", + "provider": "openstreet", + "layerType": "OpenStreetMap.Mapnik" + }, + { + "label": "{i18n:widgets.maps.layer.satellite}", + "provider": "openstreet", + "layerType": "Esri.WorldImagery" + }, + { + "label": "{i18n:widgets.maps.layer.hybrid}", + "provider": "openstreet", + "layerType": "Esri.WorldImagery", + "referenceLayer": "openstreetmap_hybrid" + } + ], + "imageSource": null, + "markers": [ + { + "dsType": "entity", + "dsLabel": "", + "dsDeviceId": null, + "dsEntityAliasId": "68a058e1-fdda-8482-715b-3ae4a488568e", + "dsFilterId": null, + "additionalDataKeys": [ + { + "name": "temperature", + "type": "timeseries", + "label": "temperature", + "color": "#2196f3", + "settings": {}, + "_hash": 0.570889787682481, + "aggregationType": "NONE", + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return value || \"\";" + }, + { + "name": "humidity", + "type": "timeseries", + "label": "humidity", + "color": "#2196f3", + "settings": {}, + "_hash": 0.13597394595782442, + "aggregationType": "NONE", + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return value || \"\";" + }, + { + "name": "active", + "type": "attribute", + "label": "active", + "color": "#2196f3", + "settings": {}, + "_hash": 0.21080919932756603 + } + ], + "label": { + "show": true, + "type": "function", + "pattern": "${entityName}", + "patternFunction": "var color;\nif(data.active !== \"true\"){\n color = 'rgb(255, 0, 0)';\n} else {\n color = 'rgb(39, 134, 34)';\n}\nreturn '' + \n '${entityLabel}' + \n ''" + }, + "tooltip": { + "show": true, + "trigger": "click", + "autoclose": true, + "type": "pattern", + "pattern": "${entityName}

Temperature: ${temperature:1} °C
Humidity: ${humidity:0} %

Delete", + "offsetX": 0, + "offsetY": -1, + "patternFunction": null, + "tagActions": [ + { + "name": "delete", + "type": "custom", + "customFunction": "widgetContext.mapInstance.saveMarkerLocation(additionalParams, null, null).subscribe(function success() {\n widgetContext.updateAliases();\n});", + "openInSeparateDialog": false, + "openInPopover": false + } + ] + }, + "click": { + "type": "doNothing" + }, + "groups": null, + "edit": { + "enabledActions": [ + "add", + "move", + "remove" + ], + "attributeScope": "SERVER_SCOPE", + "snappable": false + }, + "xKey": { + "name": "latitude", + "label": "latitude", + "type": "attribute", + "settings": {}, + "color": "#2196f3" + }, + "yKey": { + "name": "longitude", + "label": "longitude", + "type": "attribute", + "settings": {}, + "color": "#2196f3" + }, + "markerType": "icon", + "markerShape": { + "shape": "markerShape1", + "size": 34, + "color": { + "type": "constant", + "color": "#307FE5" + } + }, + "markerIcon": { + "size": 48, + "color": { + "type": "function", + "color": "rgb(255, 0, 0)", + "range": null, + "colorFunction": "if (data.active !== \"true\"){\n return 'rgb(255, 0, 0)';\n} else {\n return 'rgb(39, 134, 34)';\n}" + }, + "iconContainer": "iconContainer1", + "icon": "thermostat" + }, + "markerImage": { + "type": "function", + "imageFunction": "return {};", + "images": [] + }, + "markerOffsetX": 0.5, + "markerOffsetY": 1, + "markerClustering": { + "enable": false, + "zoomOnClick": true, + "maxZoom": null, + "maxClusterRadius": 80, + "zoomAnimation": true, + "showCoverageOnHover": true, + "spiderfyOnMaxZoom": false, + "chunkedLoad": false, + "lazyLoad": true, + "useClusterMarkerColorFunction": false, + "clusterMarkerColorFunction": null + } + } + ], + "polygons": [], + "circles": [], + "additionalDataSources": [], + "controlsPosition": "topleft", + "zoomActions": [ + "scroll", + "doubleClick", + "controlButtons" + ], + "scales": [], + "dragModeButton": false, + "fitMapBounds": true, + "useDefaultCenterPosition": false, + "defaultCenterPosition": "0,0", + "defaultZoomLevel": null, + "mapPageSize": 16384, + "mapActionButtons": [], + "background": { + "type": "color", + "color": "#fff", + "overlay": { + "enabled": false, + "color": "rgba(255,255,255,0.72)", + "blur": 3 + } + }, + "padding": "8px" + }, + "title": "Map", + "useDashboardTimewindow": true, + "displayTimewindow": true, + "showTitleIcon": false, + "titleTooltip": "", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": {}, + "widgetCss": "", + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "pageSize": 1024, + "noDataDisplayMessage": "", + "configMode": "advanced", + "titleFont": null, + "titleColor": null, + "margin": "0px", + "borderRadius": "0px", + "iconSize": "24px", + "titleIcon": "map", + "iconColor": "#1F6BDD", + "actions": {} + }, + "row": 0, + "col": 0, + "id": "1bbd4b8d-db42-ed7c-70c2-3d32c771843d" + }, + "c9da2d6b-4ba9-1ac8-63b8-68ab49b13c81": { + "typeFullFqn": "system.map", + "type": "latest", + "sizeX": 8.5, + "sizeY": 6, + "config": { + "datasources": [], + "timewindow": { + "displayValue": "", + "selectedTab": 0, + "realtime": { + "realtimeType": 1, + "interval": 1000, + "timewindowMs": 60000, + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideQuickInterval": false + }, + "history": { + "historyType": 0, + "interval": 1000, + "timewindowMs": 60000, + "fixedTimewindow": { + "startTimeMs": 1741884755143, + "endTimeMs": 1741971155143 + }, + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideFixedInterval": false, + "hideQuickInterval": false + }, + "aggregation": { + "type": "AVG", + "limit": 25000 + } + }, + "showTitle": false, + "backgroundColor": "rgba(0, 0, 0, 0)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "0px", + "settings": { + "mapType": "geoMap", + "layers": [ + { + "label": "{i18n:widgets.maps.layer.roadmap}", + "provider": "openstreet", + "layerType": "OpenStreetMap.Mapnik" + }, + { + "label": "{i18n:widgets.maps.layer.satellite}", + "provider": "openstreet", + "layerType": "Esri.WorldImagery" + }, + { + "label": "{i18n:widgets.maps.layer.hybrid}", + "provider": "openstreet", + "layerType": "Esri.WorldImagery", + "referenceLayer": "openstreetmap_hybrid" + } + ], + "imageSource": null, + "markers": [ + { + "dsType": "entity", + "dsLabel": "", + "dsDeviceId": null, + "dsEntityAliasId": "12ae98c7-1ea2-52cf-64d5-763e9d993547", + "dsFilterId": null, + "additionalDataKeys": [ + { + "name": "temperature", + "type": "timeseries", + "label": "temperature", + "color": "#2196f3", + "settings": {}, + "_hash": 0.570889787682481, + "aggregationType": "NONE", + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return value || \"\";" + }, + { + "name": "humidity", + "type": "timeseries", + "label": "humidity", + "color": "#2196f3", + "settings": {}, + "_hash": 0.13597394595782442, + "aggregationType": "NONE", + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return value || \"\";" + }, + { + "name": "active", + "type": "attribute", + "label": "active", + "color": "#2196f3", + "settings": {}, + "_hash": 0.21080919932756603 + } + ], + "label": { + "show": true, + "type": "function", + "pattern": "${entityName}", + "patternFunction": "var color;\nif(data.active !== \"true\"){\n color = 'rgb(255, 0, 0)';\n} else {\n color = 'rgb(39, 134, 34)';\n}\nreturn '' + \n '${entityLabel}' + \n ''" + }, + "tooltip": { + "show": true, + "trigger": "click", + "autoclose": true, + "type": "pattern", + "pattern": "${entityName}

Temperature: ${temperature:1} °C
Humidity: ${humidity:0} %

Delete", + "offsetX": 0, + "offsetY": -1, + "patternFunction": null, + "tagActions": [ + { + "name": "delete", + "type": "custom", + "customFunction": "widgetContext.mapInstance.saveMarkerLocation(additionalParams, null, null).subscribe(function success() {\n widgetContext.updateAliases();\n});", + "openInSeparateDialog": false, + "openInPopover": false + } + ] + }, + "click": { + "type": "doNothing" + }, + "groups": null, + "edit": { + "enabledActions": [ + "add", + "move", + "remove" + ], + "attributeScope": "SERVER_SCOPE", + "snappable": false + }, + "xKey": { + "name": "latitude", + "label": "latitude", + "type": "attribute", + "settings": {}, + "color": "#2196f3" + }, + "yKey": { + "name": "longitude", + "label": "longitude", + "type": "attribute", + "settings": {}, + "color": "#2196f3" + }, + "markerType": "icon", + "markerShape": { + "shape": "markerShape1", + "size": 34, + "color": { + "type": "constant", + "color": "#307FE5" + } + }, + "markerIcon": { + "size": 48, + "color": { + "type": "function", + "color": "rgb(255, 0, 0)", + "range": null, + "colorFunction": "if (data.active !== \"true\"){\n return 'rgb(255, 0, 0)';\n} else {\n return 'rgb(39, 134, 34)';\n}" + }, + "iconContainer": "iconContainer1", + "icon": "thermostat" + }, + "markerImage": { + "type": "function", + "imageFunction": "return {};", + "images": [] + }, + "markerOffsetX": 0.5, + "markerOffsetY": 1, + "markerClustering": { + "enable": false, + "zoomOnClick": true, + "maxZoom": null, + "maxClusterRadius": 80, + "zoomAnimation": true, + "showCoverageOnHover": true, + "spiderfyOnMaxZoom": false, + "chunkedLoad": false, + "lazyLoad": true, + "useClusterMarkerColorFunction": false, + "clusterMarkerColorFunction": null + } + } + ], + "polygons": [], + "circles": [], + "additionalDataSources": [], + "controlsPosition": "topleft", + "zoomActions": [ + "scroll", + "doubleClick", + "controlButtons" + ], + "scales": [], + "dragModeButton": false, + "fitMapBounds": true, + "useDefaultCenterPosition": false, + "defaultCenterPosition": "0,0", + "defaultZoomLevel": 5, + "mapPageSize": 16384, + "mapActionButtons": [], + "background": { + "type": "color", + "color": "#fff", + "overlay": { + "enabled": false, + "color": "rgba(255,255,255,0.72)", + "blur": 3 + } + }, + "padding": "8px" + }, + "title": "Map", + "useDashboardTimewindow": true, + "displayTimewindow": true, + "showTitleIcon": false, + "titleTooltip": "", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": {}, + "widgetCss": "", + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "pageSize": 1024, + "noDataDisplayMessage": "", + "configMode": "advanced", + "titleFont": null, + "titleColor": null, + "margin": "0px", + "borderRadius": "0px", + "iconSize": "24px", + "titleIcon": "map", + "iconColor": "#1F6BDD", + "actions": {} + }, + "row": 0, + "col": 0, + "id": "c9da2d6b-4ba9-1ac8-63b8-68ab49b13c81" + } + }, + "states": { + "default": { + "name": "Thermostats", + "root": true, + "layouts": { + "main": { + "widgets": { + "f33c746c-0dfc-c212-395b-b448c8a17209": { + "sizeX": 11, "sizeY": 11, "row": 0, "col": 0, @@ -1730,11 +2054,12 @@ "mobileOrder": 2, "mobileHeight": 5 }, - "3da9a9a1-0b9a-2e1f-0dcb-0ff34a695abb": { + "5186d1e3-f076-e062-5129-e4dd8e4adfb0": { "sizeX": 13, "sizeY": 6, "row": 5, "col": 11, + "resizable": true, "mobileOrder": 3, "mobileHeight": 5 } @@ -1748,7 +2073,8 @@ "mobileAutoFillHeight": false, "mobileRowHeight": 70, "margin": 10, - "outerMargin": true + "outerMargin": true, + "layoutType": "default" } } } @@ -1759,9 +2085,10 @@ "layouts": { "main": { "widgets": { - "00fb2742-ba1f-7e43-673f-d6c08b72ed06": { + "1bbd4b8d-db42-ed7c-70c2-3d32c771843d": { "sizeX": 24, - "sizeY": 12, + "sizeY": 11, + "resizable": true, "row": 0, "col": 0 } @@ -1775,7 +2102,8 @@ "mobileAutoFillHeight": false, "mobileRowHeight": 70, "margin": 10, - "outerMargin": true + "outerMargin": true, + "layoutType": "default" } } } @@ -1794,14 +2122,6 @@ "mobileOrder": 3, "mobileHeight": 5 }, - "0a430429-9078-9ae6-2b67-e4a15a2bf8bf": { - "sizeX": 6, - "sizeY": 6, - "row": 6, - "col": 0, - "mobileOrder": 4, - "mobileHeight": 6 - }, "eda8a397-0959-690c-405c-11e2c9b2bc7e": { "sizeX": 18, "sizeY": 6, @@ -1817,6 +2137,13 @@ "col": 6, "mobileOrder": 2, "mobileHeight": 6 + }, + "c9da2d6b-4ba9-1ac8-63b8-68ab49b13c81": { + "sizeX": 6, + "sizeY": 6, + "resizable": true, + "row": 6, + "col": 0 } }, "gridSettings": { @@ -1828,7 +2155,8 @@ "mobileAutoFillHeight": false, "mobileRowHeight": 70, "margin": 10, - "outerMargin": true + "outerMargin": true, + "layoutType": "default" } } } @@ -1861,7 +2189,6 @@ "timewindow": { "displayValue": "", "selectedTab": 0, - "hideInterval": false, "hideAggregation": false, "hideAggInterval": false, "realtime": { @@ -1889,33 +2216,17 @@ "showEntitiesSelect": true, "showDashboardTimewindow": true, "showDashboardExport": true, - "toolbarAlwaysOpen": true + "toolbarAlwaysOpen": true, + "titleColor": "rgba(0,0,0,0.870588)", + "showDashboardLogo": false, + "dashboardLogoUrl": null, + "hideToolbar": false, + "showFilters": true, + "showUpdateDashboardImage": true, + "dashboardCss": "" }, "filters": {} }, "name": "Thermostats", - "resources": [ - { - "link": "/api/images/system/thermostats_dashboard_widget_thermostat_maps_marker_image_0.svg", - "title": "\"Thermostats\" dashboard widget \"Thermostat maps\" marker image 0", - "type": "IMAGE", - "subType": "IMAGE", - "fileName": "thermostats_dashboard_widget_thermostat_maps_marker_image_0.svg", - "publicResourceKey": "DXvJsh8m6v4NOcO6AHXXm9kQzfrVgisT", - "mediaType": "image/svg+xml", - "data": "PHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJzdmc0NDA4IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHg9IjAiIHk9IjAiIHZpZXdCb3g9IjAgMCAxNTAgMTUwIiB4bWw6c3BhY2U9InByZXNlcnZlIj48c3R5bGU+LnN0MntmaWxsOiNmNDQzMzZ9PC9zdHlsZT48ZyBpZD0ibGF5ZXIxIj48ZyBpZD0icGF0aDY4ODEtMy01LTUtMS04LTQtNC03LTgiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNDYuNDM4IC0yNzYuMDI4KSIgb3BhY2l0eT0iLjg5MiI+PHJhZGlhbEdyYWRpZW50IGlkPSJTVkdJRF8xXyIgY3g9IjMwODUuMjE1IiBjeT0iMzE3OC40NTgiIHI9IjQ5LjkwMSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCguNjc5MyAuMDA3NiAtLjUwOSAuNTYxMiAtMjMyLjYyOSAtMTQxMS43MjUpIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agb2Zmc2V0PSIwIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLW9wYWNpdHk9Ii4xODgiLz48L3JhZGlhbEdyYWRpZW50PjxwYXRoIGQ9Ik0yODUuNiAzODguNWMxMC4zLTEyLjQgNC40LTIyLjQtMTQuNC0yMi40LTE4LjkgMC00Mi40IDEwLTUzLjkgMjIuNC0xNi44IDE4IC40IDIzLjUtLjIgMzUtLjEgMS44IDMuOSAxLjggNyAwIDE5LjgtMTEuNSA0Ni41LTE3IDYxLjUtMzUiIGZpbGw9InVybCgjU1ZHSURfMV8pIi8+PC9nPjxwYXRoIGlkPSJwYXRoNjg4MS0zLTUtNS0xLTgtNC00IiBjbGFzcz0ic3QyIiBkPSJNMTI0LjcgNjkuMWMtLjktMjcuNS0yMi4zLTQ5LjgtNDkuOC00OS44cy00OSAyMi4zLTQ5LjggNDkuOGMtMS4zIDQwLjEgMzAuNyA1Mi4yIDQ0LjcgNzggMi4yIDQgOCA0IDEwLjEgMCAxNC4xLTI1LjggNDYuMS0zNy45IDQ0LjgtNzgiLz48L2c+PGcgaWQ9Imc0OTI4Ij48Y2lyY2xlIGlkPSJwYXRoNDk3OCIgY2xhc3M9InN0MiIgY3g9Ijc0LjkiIGN5PSI2OS4xIiByPSI0OS45Ii8+PGcgaWQ9Imc0OTE1Ij48cGF0aCBpZD0icGF0aDY4ODMtMi0zLTUtMi00LTktNC05IiBkPSJNNzQuOCAxMDYuNGMtMjAuNiAwLTM3LjQtMTYuNy0zNy40LTM3LjQgMC0yMC42IDE2LjctMzcuNCAzNy40LTM3LjQgMjAuNiAwIDM3LjQgMTYuNyAzNy40IDM3LjRzLTE2LjcgMzcuNC0zNy40IDM3LjQiIGZpbGw9IiNmZmYiLz48L2c+PC9nPjxwYXRoIGNsYXNzPSJzdDIiIGQ9Ik05NS45IDQ2LjZWNDloLTEwdi0yLjVsMTAgLjF6bS0yIDUuM2gtOHYyLjVoOHYtMi41em0tOCA3LjloNnYtMi41aC02djIuNXptNCAyLjloLTR2Mi41aDR2LTIuNXptLTQgNy44aDJWNjhoLTJ2Mi41em0xLjUgMTRjMCA2LjktNS41IDEyLjUtMTIuMyAxMi41cy0xMi4zLTUuNi0xMi4zLTEyLjVjMC00LjUgMi4zLTguNSA2LjEtMTAuN1Y0NS41YzAtMy41IDIuOC02LjMgNi4yLTYuM3M2LjIgMi44IDYuMiA2LjN2MjguM2MzLjggMi4yIDYuMSA2LjMgNi4xIDEwLjd6bS0yLjQgMGMwLTMuOC0yLjEtNy4yLTUuNC04LjlsLS43LS4zVjQ1LjVjMC0yLjEtMS43LTMuOC0zLjgtMy44LTIuMSAwLTMuOCAxLjctMy44IDMuOHYyOS44bC0uNy4zYy0zLjMgMS43LTUuNCA1LjEtNS40IDguOSAwIDUuNSA0LjQgMTAgOS45IDEwUzg1IDkwIDg1IDg0LjV6bS0yLjEgMGMwIDQuNC0zLjUgOC03LjggOHMtNy44LTMuNi03LjgtOGMwLTMuNiAyLjQtNi44IDUuOC03LjdsLjUtLjFWNjEuNWgzLjF2MTUuMmwuNS4xYzMuMyAxIDUuNyA0LjEgNS43IDcuN3ptLTcuNC01LjNjLS4yLS44LTEtMS40LTEuOS0xLjItMyAuNy01IDMuMy01IDYuNCAwIC45LjcgMS42IDEuNiAxLjZzMS42LS43IDEuNi0xLjZjMC0xLjYgMS4xLTMgMi42LTMuMy43LS4yIDEuMy0xIDEuMS0xLjl6Ii8+PC9zdmc+", - "public": true - }, - { - "link": "/api/images/system/thermostats_dashboard_widget_thermostat_maps_marker_image_1.svg", - "title": "\"Thermostats\" dashboard widget \"Thermostat maps\" marker image 1", - "type": "IMAGE", - "subType": "IMAGE", - "fileName": "thermostats_dashboard_widget_thermostat_maps_marker_image_1.svg", - "publicResourceKey": "HtfNoQ7FAZKdeH4m3WGodwajcsscKrTR", - "mediaType": "image/svg+xml", - "data": "PHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJzdmc0NDA4IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHg9IjAiIHk9IjAiIHZpZXdCb3g9IjAgMCAxNTAgMTUwIiB4bWw6c3BhY2U9InByZXNlcnZlIj48c3R5bGU+LnN0MntmaWxsOiMyNzg2MjJ9PC9zdHlsZT48ZyBpZD0ibGF5ZXIxIj48ZyBpZD0icGF0aDY4ODEtMy01LTUtMS04LTQtNC03LTgiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNDYuNDM4IC0yNzYuMDI4KSIgb3BhY2l0eT0iLjg5MiI+PHJhZGlhbEdyYWRpZW50IGlkPSJTVkdJRF8xXyIgY3g9IjMwODUuMjE1IiBjeT0iMzE3OC40NTgiIHI9IjQ5LjkwMSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCguNjc5MyAuMDA3NiAtLjUwOSAuNTYxMiAtMjMyLjYyOSAtMTQxMS43MjUpIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agb2Zmc2V0PSIwIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLW9wYWNpdHk9Ii4xODgiLz48L3JhZGlhbEdyYWRpZW50PjxwYXRoIGQ9Ik0yODUuNiAzODguNWMxMC4zLTEyLjQgNC40LTIyLjQtMTQuNC0yMi40LTE4LjkgMC00Mi40IDEwLTUzLjkgMjIuNC0xNi44IDE4IC40IDIzLjUtLjIgMzUtLjEgMS44IDMuOSAxLjggNyAwIDE5LjgtMTEuNSA0Ni41LTE3IDYxLjUtMzUiIGZpbGw9InVybCgjU1ZHSURfMV8pIi8+PC9nPjxwYXRoIGlkPSJwYXRoNjg4MS0zLTUtNS0xLTgtNC00IiBjbGFzcz0ic3QyIiBkPSJNMTI0LjcgNjkuMWMtLjktMjcuNS0yMi4zLTQ5LjgtNDkuOC00OS44cy00OSAyMi4zLTQ5LjggNDkuOGMtMS4zIDQwLjEgMzAuNyA1Mi4yIDQ0LjcgNzggMi4yIDQgOCA0IDEwLjEgMCAxNC4xLTI1LjggNDYuMS0zNy45IDQ0LjgtNzgiLz48L2c+PGcgaWQ9Imc0OTI4Ij48Y2lyY2xlIGlkPSJwYXRoNDk3OCIgY2xhc3M9InN0MiIgY3g9Ijc0LjkiIGN5PSI2OS4xIiByPSI0OS45Ii8+PGcgaWQ9Imc0OTE1Ij48cGF0aCBpZD0icGF0aDY4ODMtMi0zLTUtMi00LTktNC05IiBkPSJNNzQuOCAxMDYuNGMtMjAuNiAwLTM3LjQtMTYuNy0zNy40LTM3LjQgMC0yMC42IDE2LjctMzcuNCAzNy40LTM3LjQgMjAuNiAwIDM3LjQgMTYuNyAzNy40IDM3LjRzLTE2LjcgMzcuNC0zNy40IDM3LjQiIGZpbGw9IiNmZmYiLz48L2c+PC9nPjxwYXRoIGNsYXNzPSJzdDIiIGQ9Ik05NS45IDQ2LjZWNDloLTEwdi0yLjVsMTAgLjF6bS0yIDUuM2gtOHYyLjVoOHYtMi41em0tOCA3LjloNnYtMi41aC02djIuNXptNCAyLjloLTR2Mi41aDR2LTIuNXptLTQgNy44aDJWNjhoLTJ2Mi41em0xLjUgMTRjMCA2LjktNS41IDEyLjUtMTIuMyAxMi41cy0xMi4zLTUuNi0xMi4zLTEyLjVjMC00LjUgMi4zLTguNSA2LjEtMTAuN1Y0NS41YzAtMy41IDIuOC02LjMgNi4yLTYuM3M2LjIgMi44IDYuMiA2LjN2MjguM2MzLjggMi4yIDYuMSA2LjMgNi4xIDEwLjd6bS0yLjQgMGMwLTMuOC0yLjEtNy4yLTUuNC04LjlsLS43LS4zVjQ1LjVjMC0yLjEtMS43LTMuOC0zLjgtMy44LTIuMSAwLTMuOCAxLjctMy44IDMuOHYyOS44bC0uNy4zYy0zLjMgMS43LTUuNCA1LjEtNS40IDguOSAwIDUuNSA0LjQgMTAgOS45IDEwUzg1IDkwIDg1IDg0LjV6bS0yLjEgMGMwIDQuNC0zLjUgOC03LjggOHMtNy44LTMuNi03LjgtOGMwLTMuNiAyLjQtNi44IDUuOC03LjdsLjUtLjFWNjEuNWgzLjF2MTUuMmwuNS4xYzMuMyAxIDUuNyA0LjEgNS43IDcuN3ptLTcuNC01LjNjLS4yLS44LTEtMS40LTEuOS0xLjItMyAuNy01IDMuMy01IDYuNCAwIC45LjcgMS42IDEuNiAxLjZzMS42LS43IDEuNi0xLjZjMC0xLjYgMS4xLTMgMi42LTMuMy43LS4yIDEuMy0xIDEuMS0xLjl6Ii8+PC9zdmc+Cg==", - "public": true - } - ] + "resources": [] } \ No newline at end of file diff --git a/application/src/main/data/json/edge/rule_chains/edge_root_rule_chain.json b/application/src/main/data/json/edge/rule_chains/edge_root_rule_chain.json index 6701b59e0e..81f9e6a14d 100644 --- a/application/src/main/data/json/edge/rule_chains/edge_root_rule_chain.json +++ b/application/src/main/data/json/edge/rule_chains/edge_root_rule_chain.json @@ -50,8 +50,11 @@ }, "type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode", "name": "Save Client Attributes", - "configurationVersion": 2, + "configurationVersion": 3, "configuration": { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, "scope": "CLIENT_SCOPE", "notifyDevice": false, "sendAttributesUpdatedNotification": false, @@ -119,7 +122,7 @@ "type": "org.thingsboard.rule.engine.edge.TbMsgPushToCloudNode", "name": "Push to cloud", "configuration": { - "scope": "SERVER_SCOPE" + "scope": "CLIENT_SCOPE" }, "externalId": null }, diff --git a/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg index 4e41fd77c6..37b3a199c6 100644 --- a/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Bottom right elbow connector", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `-${dashWidth + (dashGap || dashWidth)}` : `${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", "tags": [ { "tag": "line", @@ -10,23 +11,132 @@ "actions": null } ], - "behavior": [], + "behavior": [ + { + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "animationDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], "properties": [ { "id": "mainLine", "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -37,12 +147,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -51,32 +160,95 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "color", + "default": "#C8DFF7", + "disabled": false, + "visible": true + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg index 33942432cb..2feb95e9ff 100644 --- a/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Bottom tee connector", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H100\";\nconst rightLine = \"M100 100H200\";\nconst bottomLine = \"M 100,200 V 103\";\n\nprepareFlowAnimation('left', leftLine);\nprepareFlowAnimation('right', rightLine);\nprepareFlowAnimation('bottom', bottomLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(``);\n}", "tags": [ { "tag": "line", @@ -15,23 +16,364 @@ "actions": null } ], - "behavior": [], + "behavior": [ + { + "id": "leftFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], "properties": [ { "id": "mainLine", "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -42,12 +384,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -56,32 +397,95 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "color", + "default": "#C8DFF7", + "disabled": false, + "visible": true + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg index df708d47d3..ab6798b48b 100644 --- a/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Cross connector", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H100\";\nconst topLine = \"M100 97L100 0\";\nconst rightLine = \"M100 100H200\";\nconst bottomLine = \"M 100,200 V 103\";\n\nprepareFlowAnimation('left', leftLine);\nprepareFlowAnimation('top', topLine);\nprepareFlowAnimation('right', rightLine);\nprepareFlowAnimation('bottom', bottomLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(``);\n}", "tags": [ { "tag": "line", @@ -15,23 +16,480 @@ "actions": null } ], - "behavior": [], + "behavior": [ + { + "id": "leftFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], "properties": [ { "id": "mainLine", "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -42,12 +500,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -56,32 +513,95 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "color", + "default": "#C8DFF7", + "disabled": false, + "visible": true + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/cylindrical-tank.svg b/application/src/main/data/json/system/scada_symbols/cylindrical-tank.svg index 513809cca5..acb895cd5a 100644 --- a/application/src/main/data/json/system/scada_symbols/cylindrical-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/cylindrical-tank.svg @@ -33,7 +33,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 205;\n var majorIntervalLength = 760 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(340, y, 372, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(352, minorY, 372, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 205;\n var majorIntervalLength = 760 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(340, y, 372, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(352, minorY, 372, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -278,80 +278,43 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#E5E5E5", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#1EC1F480", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBox", "name": "{i18n:scada.symbol.value-box}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBoxColor", "name": "{i18n:scada.symbol.value-box}", "type": "color", "default": "#F3F3F3", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-text}", "type": "units", "default": "gal", - "required": null, "subLabel": "{i18n:scada.symbol.units}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "valueTextFont", @@ -364,80 +327,78 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#0000008A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ], + "disabled": false, + "visible": true }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 10, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -450,128 +411,84 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#00000061", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorWarningColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorCriticalColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#0000001F", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorWarningColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorCriticalColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/elevated-tank.svg b/application/src/main/data/json/system/scada_symbols/elevated-tank.svg index 9db211457e..48668e9851 100644 --- a/application/src/main/data/json/system/scada_symbols/elevated-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/elevated-tank.svg @@ -34,7 +34,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 265;\n var majorIntervalLength = 895 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(825, y, 857, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 815, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(837, minorY, 857, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 265;\n var majorIntervalLength = 895 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(825, y, 857, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 815, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(837, minorY, 857, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -274,80 +274,43 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#E5E5E5", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#1EC1F480", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBox", "name": "{i18n:scada.symbol.value-box}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBoxColor", "name": "{i18n:scada.symbol.value-box}", "type": "color", "default": "#F3F3F3", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-text}", "type": "units", "default": "gal", - "required": null, "subLabel": "{i18n:scada.symbol.units}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextFont", @@ -360,80 +323,78 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#0000008A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "transparent", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ], + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 10, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -446,128 +407,84 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#00000061", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorWarningColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorCriticalColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#0000001F", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorWarningColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorCriticalColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg index 33b6c0a222..74d048e884 100644 --- a/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Horizontal connector with an optional directional arrow to visually indicate flow.", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", "tags": [ { "tag": "arrow", @@ -85,6 +86,83 @@ }, "defaultSetValueSettings": null, "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null } ], "properties": [ @@ -93,16 +171,8 @@ "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -113,12 +183,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -127,48 +196,93 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { - "id": "arrowColor", - "name": "{i18n:scada.symbol.arrow-color}", + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", "type": "color", - "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "default": "#C8DFF7" + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/horizontal-tank-hp.svg b/application/src/main/data/json/system/scada_symbols/horizontal-tank-hp.svg index cff6a97eb9..18f194684b 100644 --- a/application/src/main/data/json/system/scada_symbols/horizontal-tank-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/horizontal-tank-hp.svg @@ -38,7 +38,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 3;\n var majorIntervalLength = 592 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(208, y, 240, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n if (i === 0) {\n majorTickText.attr({x: 198, y: y + 10, 'text-anchor': 'end', class: 'majorTickText'});\n } else if (i === majorIntervals) {\n majorTickText.attr({x: 198, y: y - 5, 'text-anchor': 'end', class: 'majorTickText'});\n } else {\n majorTickText.attr({x: 198, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n }\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(220, minorY, 240, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 3;\n var majorIntervalLength = 592 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(208, y, 240, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n if (i === 0) {\n majorTickText.attr({x: 198, y: y + 10, 'text-anchor': 'end', class: 'majorTickText'});\n } else if (i === majorIntervals) {\n majorTickText.attr({x: 198, y: y - 5, 'text-anchor': 'end', class: 'majorTickText'});\n } else {\n majorTickText.attr({x: 198, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n }\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(220, minorY, 240, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -307,64 +307,67 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#EBEBEB", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#C8DFF7", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ], + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 10, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": false, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -377,96 +380,57 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks}", "type": "color", "default": "#00000061", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks}", "type": "color", "default": "#0000001F", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "warningColor", "name": "{i18n:scada.symbol.alarm-colors}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "criticalColor", "name": "{i18n:scada.symbol.alarm-colors}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/horizontal-tank.svg b/application/src/main/data/json/system/scada_symbols/horizontal-tank.svg index 7875b5dde0..b6b6eede7b 100644 --- a/application/src/main/data/json/system/scada_symbols/horizontal-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/horizontal-tank.svg @@ -33,7 +33,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 17;\n var majorIntervalLength = 568 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(715, y, 747, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 705, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(727, minorY, 747, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 17;\n var majorIntervalLength = 568 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(715, y, 747, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 705, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(727, minorY, 747, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -278,80 +278,43 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#E5E5E5", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#1EC1F480", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBox", "name": "{i18n:scada.symbol.value-box}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBoxColor", "name": "{i18n:scada.symbol.value-box}", "type": "color", "default": "#F3F3F3", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-text}", "type": "units", "default": "gal", - "required": null, "subLabel": "{i18n:scada.symbol.units}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextFont", @@ -364,80 +327,78 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#0000008A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ], + "disabled": false, + "visible": true }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 10, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -450,128 +411,84 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#00000061", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorWarningColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorCriticalColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#0000001F", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorWarningColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorCriticalColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/large-cylindrical-tank.svg b/application/src/main/data/json/system/scada_symbols/large-cylindrical-tank.svg index 772e34f0f7..69360fbe9c 100644 --- a/application/src/main/data/json/system/scada_symbols/large-cylindrical-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/large-cylindrical-tank.svg @@ -33,7 +33,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 60;\n var majorIntervalLength = 910 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(656, y, 688, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 646, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(668, minorY, 688, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 60;\n var majorIntervalLength = 910 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(656, y, 688, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 646, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(668, minorY, 688, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -278,80 +278,43 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#E5E5E5", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#1EC1F480", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBox", "name": "{i18n:scada.symbol.value-box}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBoxColor", "name": "{i18n:scada.symbol.value-box}", "type": "color", "default": "#F3F3F3", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-text}", "type": "units", "default": "gal", - "required": null, "subLabel": "{i18n:scada.symbol.units}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextFont", @@ -364,80 +327,78 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#0000008A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ], + "disabled": false, + "visible": true }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 10, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -450,128 +411,84 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#00000061", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorWarningColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorCriticalColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#0000001F", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorWarningColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorCriticalColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/large-stand-cylindrical-tank.svg b/application/src/main/data/json/system/scada_symbols/large-stand-cylindrical-tank.svg index dc9cb47268..11bd47916f 100644 --- a/application/src/main/data/json/system/scada_symbols/large-stand-cylindrical-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/large-stand-cylindrical-tank.svg @@ -34,7 +34,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 60;\n var majorIntervalLength = 910 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(656, y, 688, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 646, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(668, minorY, 688, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 60;\n var majorIntervalLength = 910 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(656, y, 688, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 646, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(668, minorY, 688, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -279,80 +279,43 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#E5E5E5", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#1EC1F480", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBox", "name": "{i18n:scada.symbol.value-box}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBoxColor", "name": "{i18n:scada.symbol.value-box}", "type": "color", "default": "#F3F3F3", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-text}", "type": "units", "default": "gal", - "required": null, "subLabel": "{i18n:scada.symbol.units}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextFont", @@ -365,80 +328,76 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#0000008A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ] }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 10, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -451,128 +410,84 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#00000061", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorWarningColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorCriticalColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#0000001F", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorWarningColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorCriticalColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/large-stand-vertical-tank.svg b/application/src/main/data/json/system/scada_symbols/large-stand-vertical-tank.svg index b7cb67f2a0..d9c05bde40 100644 --- a/application/src/main/data/json/system/scada_symbols/large-stand-vertical-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/large-stand-vertical-tank.svg @@ -34,7 +34,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 203;\n var majorIntervalLength = 763 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(676, y, 708, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 666, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(688, minorY, 708, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 203;\n var majorIntervalLength = 763 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(676, y, 708, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 666, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(688, minorY, 708, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -279,80 +279,43 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#E5E5E5", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#1EC1F480", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBox", "name": "{i18n:scada.symbol.value-box}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBoxColor", "name": "{i18n:scada.symbol.value-box}", "type": "color", "default": "#F3F3F3", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-text}", "type": "units", "default": "gal", - "required": null, "subLabel": "{i18n:scada.symbol.units}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextFont", @@ -365,80 +328,78 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#0000008A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ], + "disabled": false, + "visible": true }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 10, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -451,128 +412,84 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#00000061", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorWarningColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorCriticalColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#0000001F", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorWarningColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorCriticalColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/large-vertical-tank.svg b/application/src/main/data/json/system/scada_symbols/large-vertical-tank.svg index 3b9420e04d..cc168915ad 100644 --- a/application/src/main/data/json/system/scada_symbols/large-vertical-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/large-vertical-tank.svg @@ -33,7 +33,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 203;\n var majorIntervalLength = 763 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(676, y, 708, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 666, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(688, minorY, 708, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 203;\n var majorIntervalLength = 763 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(676, y, 708, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 666, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(688, minorY, 708, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -278,80 +278,43 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#E5E5E5", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#1EC1F480", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBox", "name": "{i18n:scada.symbol.value-box}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBoxColor", "name": "{i18n:scada.symbol.value-box}", "type": "color", "default": "#F3F3F3", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-text}", "type": "units", "default": "gal", - "required": null, "subLabel": "{i18n:scada.symbol.units}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextFont", @@ -364,80 +327,78 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#0000008A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ], + "disabled": false, + "visible": true }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 10, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -450,128 +411,84 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#00000061", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorWarningColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorCriticalColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#0000001F", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorWarningColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorCriticalColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg index bfc97fe928..fd14834d12 100644 --- a/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Left bottom elbow connector", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", "tags": [ { "tag": "line", @@ -10,23 +11,132 @@ "actions": null } ], - "behavior": [], + "behavior": [ + { + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "animationDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], "properties": [ { "id": "mainLine", "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -37,12 +147,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -51,32 +160,93 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "color", + "default": "#C8DFF7" + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg index eac3869a1a..af83a4abb3 100644 --- a/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Left tee connector", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H97\";\nconst topLine = \"M100 100L100 0\";\nconst bottomLine = \"M 100,200 V 100\";\n\nprepareFlowAnimation('left', leftLine);\nprepareFlowAnimation('top', topLine);\nprepareFlowAnimation('bottom', bottomLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(``);\n}", "tags": [ { "tag": "line", @@ -15,23 +16,364 @@ "actions": null } ], - "behavior": [], + "behavior": [ + { + "id": "leftFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], "properties": [ { "id": "mainLine", "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -42,12 +384,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -56,32 +397,95 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "color", + "default": "#C8DFF7", + "disabled": false, + "visible": true + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg index 6dfbc9e265..5b6d30ab65 100644 --- a/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Left top elbow connector", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", "tags": [ { "tag": "line", @@ -10,23 +11,132 @@ "actions": null } ], - "behavior": [], + "behavior": [ + { + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "animationDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], "properties": [ { "id": "mainLine", "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -37,12 +147,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -51,32 +160,95 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "color", + "default": "#C8DFF7", + "disabled": false, + "visible": true + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/long-bottom-filter.svg b/application/src/main/data/json/system/scada_symbols/long-bottom-filter.svg index 689851529d..ed6eb25bf0 100644 --- a/application/src/main/data/json/system/scada_symbols/long-bottom-filter.svg +++ b/application/src/main/data/json/system/scada_symbols/long-bottom-filter.svg @@ -1,7 +1,7 @@ { - "title": "Long bottom filter with configurable click actions for custom operations, dashboard manipulation, etc.", - "description": "Long bottom filter", + "title": "Long bottom filter", + "description": "Long bottom filter with configurable click actions for custom operations, dashboard manipulation, etc.", "searchTags": [ "filter" ], diff --git a/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg index 86bb07a520..3e65bd6b04 100644 --- a/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg @@ -4,6 +4,7 @@ "description": "Long horizontal connector with an optional directional arrow to visually indicate flow.", "widgetSizeX": 2, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(`<path style=\"stroke-dasharray: ${dashArray}; stroke-linecap: ${dashCap}; stroke-dashoffset: 0;\" d=\"${line}\" stroke-miterlimit=\"10\" fill=\"none\" stroke=\"${lineColor}\" stroke-width=\"${lineWidth}\"><animate attributeName=\"stroke-dashoffset\" values=\"${value};0\" dur=\"${duration}s\" begin=\"-${offset}ms\" calcMode=\"linear\" repeatCount=\"indefinite\" /></path>`);\n}\n", "tags": [ { "tag": "arrow", @@ -86,6 +87,83 @@ }, "defaultSetValueSettings": null, "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null } ], "properties": [ @@ -94,16 +172,8 @@ "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -114,12 +184,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -128,49 +197,93 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { - "id": "arrowColor", - "name": "{i18n:scada.symbol.arrow-color}", + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", "type": "color", - "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "default": "#C8DFF7" + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] } - - - + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/long-top-filter.svg b/application/src/main/data/json/system/scada_symbols/long-top-filter.svg index 9b287cbe46..d23059de37 100644 --- a/application/src/main/data/json/system/scada_symbols/long-top-filter.svg +++ b/application/src/main/data/json/system/scada_symbols/long-top-filter.svg @@ -1,7 +1,7 @@ { - "title": "Long top filter with configurable click actions for custom operations, dashboard manipulation, etc.", - "description": "Title\nLong top filter\n", + "title": "Long top filter", + "description": "Long top filter with configurable click actions for custom operations, dashboard manipulation, etc.", "searchTags": [ "filter" ], diff --git a/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg index d7a0aa8aa7..b5c9730842 100644 --- a/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Long vertical connector with an optional directional arrow to visually indicate flow.", "widgetSizeX": 1, "widgetSizeY": 2, + "stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(`<path style=\"stroke-dasharray: ${dashArray}; stroke-linecap: ${dashCap}; stroke-dashoffset: 0;\" d=\"${line}\" stroke-miterlimit=\"10\" fill=\"none\" stroke=\"${lineColor}\" stroke-width=\"${lineWidth}\"><animate attributeName=\"stroke-dashoffset\" values=\"${value};0\" dur=\"${duration}s\" begin=\"-${offset}ms\" calcMode=\"linear\" repeatCount=\"indefinite\" /></path>`);\n}\n", "tags": [ { "tag": "arrow", @@ -85,6 +86,83 @@ }, "defaultSetValueSettings": null, "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null } ], "properties": [ @@ -93,16 +171,8 @@ "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -113,12 +183,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -127,48 +196,93 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { - "id": "arrowColor", - "name": "{i18n:scada.symbol.arrow-color}", + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", "type": "color", - "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "default": "#C8DFF7" + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] } - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/pool-hp.svg b/application/src/main/data/json/system/scada_symbols/pool-hp.svg index 357563a687..925ea8906a 100644 --- a/application/src/main/data/json/system/scada_symbols/pool-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/pool-hp.svg @@ -38,7 +38,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 3;\n var majorIntervalLength = 792 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(298, y, 330, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n if (i === 0) {\n majorTickText.attr({x: 288, y: y + 10, 'text-anchor': 'end', class: 'majorTickText'});\n } else if (i === majorIntervals) {\n majorTickText.attr({x: 288, y: y - 5, 'text-anchor': 'end', class: 'majorTickText'});\n } else {\n majorTickText.attr({x: 288, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n }\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(310, minorY, 330, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 3;\n var majorIntervalLength = 792 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(298, y, 330, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n if (i === 0) {\n majorTickText.attr({x: 288, y: y + 10, 'text-anchor': 'end', class: 'majorTickText'});\n } else if (i === majorIntervals) {\n majorTickText.attr({x: 288, y: y - 5, 'text-anchor': 'end', class: 'majorTickText'});\n } else {\n majorTickText.attr({x: 288, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n }\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(310, minorY, 330, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -307,64 +307,57 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#EBEBEB", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#C8DFF7", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ], + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 10, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": false, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -377,96 +370,67 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks}", "type": "color", "default": "#00000061", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks}", "type": "color", "default": "#0000001F", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "warningColor", "name": "{i18n:scada.symbol.alarm-colors}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "criticalColor", "name": "{i18n:scada.symbol.alarm-colors}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg index 17d8bf4e0c..62aecb065d 100644 --- a/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Right tee connector", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst topLine = \"M100 100L100 0\";\nconst rightLine = \"M103 100H200\";\nconst bottomLine = \"M 100,200 V 100\";\n\nprepareFlowAnimation('top', topLine);\nprepareFlowAnimation('right', rightLine);\nprepareFlowAnimation('bottom', bottomLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(``);\n}", "tags": [ { "tag": "line", @@ -15,23 +16,364 @@ "actions": null } ], - "behavior": [], + "behavior": [ + { + "id": "topFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], "properties": [ { "id": "mainLine", "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -42,12 +384,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -56,32 +397,95 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "color", + "default": "#C8DFF7", + "disabled": false, + "visible": true + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/short-top-filter.svg b/application/src/main/data/json/system/scada_symbols/short-top-filter.svg index 0a77c78f00..0cb6da475c 100644 --- a/application/src/main/data/json/system/scada_symbols/short-top-filter.svg +++ b/application/src/main/data/json/system/scada_symbols/short-top-filter.svg @@ -1,7 +1,7 @@ { - "title": "Short top filter with configurable click actions for custom operations, dashboard manipulation, etc.", - "description": "Short top filter", + "title": "Short top filter", + "description": "Short top filter with configurable click actions for custom operations, dashboard manipulation, etc.", "searchTags": [ "filter" ], diff --git a/application/src/main/data/json/system/scada_symbols/short-vertical-tank-hp.svg b/application/src/main/data/json/system/scada_symbols/short-vertical-tank-hp.svg index 1f635fc810..046c5c5802 100644 --- a/application/src/main/data/json/system/scada_symbols/short-vertical-tank-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/short-vertical-tank-hp.svg @@ -38,7 +38,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 3;\n var majorIntervalLength = 594 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(170, y, 202, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n if (i === 0) {\n majorTickText.attr({x: 160, y: y + 10, 'text-anchor': 'end', class: 'majorTickText'});\n } else if (i === majorIntervals) {\n majorTickText.attr({x: 160, y: y - 5, 'text-anchor': 'end', class: 'majorTickText'});\n } else {\n majorTickText.attr({x: 160, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n }\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(182, minorY, 202, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 3;\n var majorIntervalLength = 594 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(170, y, 202, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n if (i === 0) {\n majorTickText.attr({x: 160, y: y + 10, 'text-anchor': 'end', class: 'majorTickText'});\n } else if (i === majorIntervals) {\n majorTickText.attr({x: 160, y: y - 5, 'text-anchor': 'end', class: 'majorTickText'});\n } else {\n majorTickText.attr({x: 160, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n }\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(182, minorY, 202, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -307,64 +307,67 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#EBEBEB", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#C8DFF7", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ], + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 10, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": false, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -377,96 +380,57 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks}", "type": "color", "default": "#00000061", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks}", "type": "color", "default": "#0000001F", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "warningColor", "name": "{i18n:scada.symbol.alarm-colors}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "criticalColor", "name": "{i18n:scada.symbol.alarm-colors}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] } diff --git a/application/src/main/data/json/system/scada_symbols/small-cylindrical-tank.svg b/application/src/main/data/json/system/scada_symbols/small-cylindrical-tank.svg index 85bad5d9e0..d4ddda1895 100644 --- a/application/src/main/data/json/system/scada_symbols/small-cylindrical-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/small-cylindrical-tank.svg @@ -34,7 +34,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 45;\n var majorIntervalLength = 525 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(340, y, 372, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(352, minorY, 372, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 45;\n var majorIntervalLength = 525 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(340, y, 372, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(352, minorY, 372, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -279,80 +279,43 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#E5E5E5", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#1EC1F480", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBox", "name": "{i18n:scada.symbol.value-box}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBoxColor", "name": "{i18n:scada.symbol.value-box}", "type": "color", "default": "#F3F3F3", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-text}", "type": "units", "default": "gal", - "required": null, "subLabel": "{i18n:scada.symbol.units}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "valueTextFont", @@ -365,80 +328,78 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#0000008A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ], + "disabled": false, + "visible": true }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -451,128 +412,84 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#00000061", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorWarningColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorCriticalColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#0000001F", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorWarningColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorCriticalColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/small-spherical-tank.svg b/application/src/main/data/json/system/scada_symbols/small-spherical-tank.svg index 7e6e4fa8a6..b8e3987188 100644 --- a/application/src/main/data/json/system/scada_symbols/small-spherical-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/small-spherical-tank.svg @@ -34,7 +34,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 23;\n var majorIntervalLength = 560 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(268, y, 300, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 258, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(280, minorY, 300, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 23;\n var majorIntervalLength = 560 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(268, y, 300, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 258, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(280, minorY, 300, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -279,80 +279,43 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#E5E5E5", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#1EC1F480", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBox", "name": "{i18n:scada.symbol.value-box}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBoxColor", "name": "{i18n:scada.symbol.value-box}", "type": "color", "default": "#F3F3F3", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-text}", "type": "units", "default": "gal", - "required": null, "subLabel": "{i18n:scada.symbol.units}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "valueTextFont", @@ -365,80 +328,78 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#0000008A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ], + "disabled": false, + "visible": true }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -451,128 +412,84 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#00000061", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorWarningColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorCriticalColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#0000001F", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorWarningColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorCriticalColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/spherical-tank.svg b/application/src/main/data/json/system/scada_symbols/spherical-tank.svg index 4184bcf593..cba49c63e0 100644 --- a/application/src/main/data/json/system/scada_symbols/spherical-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/spherical-tank.svg @@ -34,7 +34,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 23;\n var majorIntervalLength = 960 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(458, y, 490, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 448, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(470, minorY, 490, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 23;\n var majorIntervalLength = 960 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(458, y, 490, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 448, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(470, minorY, 490, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -279,80 +279,43 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#E5E5E5", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#1EC1F480", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBox", "name": "{i18n:scada.symbol.value-box}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBoxColor", "name": "{i18n:scada.symbol.value-box}", "type": "color", "default": "#F3F3F3", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-text}", "type": "units", "default": "gal", - "required": null, "subLabel": "{i18n:scada.symbol.units}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "valueTextFont", @@ -365,80 +328,78 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#0000008A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": true, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ], + "disabled": false, + "visible": true }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 10, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -451,128 +412,84 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#00000061", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorWarningColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorCriticalColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#0000001F", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorWarningColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorCriticalColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/stand-cylindrical-tank.svg b/application/src/main/data/json/system/scada_symbols/stand-cylindrical-tank.svg index 2ad4ac9ec8..a78060dfaf 100644 --- a/application/src/main/data/json/system/scada_symbols/stand-cylindrical-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/stand-cylindrical-tank.svg @@ -34,7 +34,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 205;\n var majorIntervalLength = 760 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(340, y, 372, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(352, minorY, 372, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 205;\n var majorIntervalLength = 760 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(340, y, 372, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(352, minorY, 372, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -279,80 +279,43 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#E5E5E5", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#1EC1F480", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBox", "name": "{i18n:scada.symbol.value-box}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBoxColor", "name": "{i18n:scada.symbol.value-box}", "type": "color", "default": "#F3F3F3", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-text}", "type": "units", "default": "gal", - "required": null, "subLabel": "{i18n:scada.symbol.units}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextFont", @@ -365,80 +328,78 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#0000008A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ], + "disabled": false, + "visible": true }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 10, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -451,128 +412,84 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#00000061", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorWarningColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorCriticalColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#0000001F", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorWarningColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorCriticalColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/stand-horizontal-tank.svg b/application/src/main/data/json/system/scada_symbols/stand-horizontal-tank.svg index 8c3745ec3d..63a21bcf08 100644 --- a/application/src/main/data/json/system/scada_symbols/stand-horizontal-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/stand-horizontal-tank.svg @@ -34,7 +34,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 17;\n var majorIntervalLength = 568 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(715, y, 747, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 705, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(727, minorY, 747, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 17;\n var majorIntervalLength = 568 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(715, y, 747, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 705, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(727, minorY, 747, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -279,80 +279,43 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#E5E5E5", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#1EC1F480", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBox", "name": "{i18n:scada.symbol.value-box}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBoxColor", "name": "{i18n:scada.symbol.value-box}", "type": "color", "default": "#F3F3F3", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-text}", "type": "units", "default": "gal", - "required": null, "subLabel": "{i18n:scada.symbol.units}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextFont", @@ -365,80 +328,78 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#0000008A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ], + "disabled": false, + "visible": true }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 10, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -451,128 +412,84 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#00000061", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorWarningColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorCriticalColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#0000001F", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorWarningColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorCriticalColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/stand-vertical-short-tank.svg b/application/src/main/data/json/system/scada_symbols/stand-vertical-short-tank.svg index 43eac50d35..96df175589 100644 --- a/application/src/main/data/json/system/scada_symbols/stand-vertical-short-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/stand-vertical-short-tank.svg @@ -35,7 +35,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 137;\n var majorIntervalLength = 442 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(523, y, 555, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 513, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(535, minorY, 555, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 137;\n var majorIntervalLength = 442 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(523, y, 555, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 513, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(535, minorY, 555, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -280,80 +280,43 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#E5E5E5", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#1EC1F480", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBox", "name": "{i18n:scada.symbol.value-box}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBoxColor", "name": "{i18n:scada.symbol.value-box}", "type": "color", "default": "#F3F3F3", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-text}", "type": "units", "default": "gal", - "required": null, "subLabel": "{i18n:scada.symbol.units}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextFont", @@ -366,80 +329,78 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#0000008A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ], + "disabled": false, + "visible": true }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -452,128 +413,84 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#00000061", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorWarningColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorCriticalColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#0000001F", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorWarningColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorCriticalColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/stand-vertical-tank.svg b/application/src/main/data/json/system/scada_symbols/stand-vertical-tank.svg index 07c3ea050c..0d0e368b9b 100644 --- a/application/src/main/data/json/system/scada_symbols/stand-vertical-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/stand-vertical-tank.svg @@ -34,7 +34,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 205;\n var majorIntervalLength = 760 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(340, y, 372, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(352, minorY, 372, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 205;\n var majorIntervalLength = 760 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(340, y, 372, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(352, minorY, 372, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -279,80 +279,43 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#E5E5E5", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#1EC1F480", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBox", "name": "{i18n:scada.symbol.value-box}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBoxColor", "name": "{i18n:scada.symbol.value-box}", "type": "color", "default": "#F3F3F3", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-text}", "type": "units", "default": "gal", - "required": null, "subLabel": "{i18n:scada.symbol.units}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextFont", @@ -365,80 +328,76 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#0000008A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ] }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 10, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -451,128 +410,84 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#00000061", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorWarningColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorCriticalColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#0000001F", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorWarningColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorCriticalColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg index f2d579c551..8ec4e3cc65 100644 --- a/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Top right elbow connector", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `-${dashWidth + (dashGap || dashWidth)}` : `${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", "tags": [ { "tag": "line", @@ -10,23 +11,132 @@ "actions": null } ], - "behavior": [], + "behavior": [ + { + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "animationDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], "properties": [ { "id": "mainLine", "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -37,12 +147,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -51,32 +160,95 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "color", + "default": "#C8DFF7", + "disabled": false, + "visible": true + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg index 004096aaa7..e4561a8347 100644 --- a/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Top tee connector", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H100\";\nconst topLine = \"M100 97L100 0\";\nconst rightLine = \"M100 100H200\";\n\nprepareFlowAnimation('left', leftLine);\nprepareFlowAnimation('top', topLine);\nprepareFlowAnimation('right', rightLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(``);\n}", "tags": [ { "tag": "line", @@ -15,23 +16,364 @@ "actions": null } ], - "behavior": [], + "behavior": [ + { + "id": "leftFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], "properties": [ { "id": "mainLine", "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -42,12 +384,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -56,32 +397,95 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "color", + "default": "#C8DFF7", + "disabled": false, + "visible": true + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg index 6ce9336fee..cfaf6793ea 100644 --- a/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Vertical connector with an optional directional arrow to visually indicate flow.", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", "tags": [ { "tag": "arrow", @@ -85,6 +86,83 @@ }, "defaultSetValueSettings": null, "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null } ], "properties": [ @@ -93,16 +171,8 @@ "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -113,12 +183,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -127,48 +196,93 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { - "id": "arrowColor", - "name": "{i18n:scada.symbol.arrow-color}", + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", "type": "color", - "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "default": "#C8DFF7" + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/vertical-short-tank.svg b/application/src/main/data/json/system/scada_symbols/vertical-short-tank.svg index d4358e8fa7..57a79d72d7 100644 --- a/application/src/main/data/json/system/scada_symbols/vertical-short-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/vertical-short-tank.svg @@ -34,7 +34,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 137;\n var majorIntervalLength = 442 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(523, y, 555, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 513, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(535, minorY, 555, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 137;\n var majorIntervalLength = 442 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(523, y, 555, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 513, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(535, minorY, 555, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -279,80 +279,43 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#E5E5E5", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#1EC1F480", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBox", "name": "{i18n:scada.symbol.value-box}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBoxColor", "name": "{i18n:scada.symbol.value-box}", "type": "color", "default": "#F3F3F3", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-text}", "type": "units", "default": "gal", - "required": null, "subLabel": "{i18n:scada.symbol.units}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextFont", @@ -365,80 +328,79 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#0000008A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ], + "disabled": false, + "visible": true }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "required": false, + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -451,128 +413,84 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#00000061", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorWarningColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorCriticalColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#0000001F", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorWarningColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorCriticalColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/vertical-tank-hp.svg b/application/src/main/data/json/system/scada_symbols/vertical-tank-hp.svg index 3e29a4e657..491fb4477e 100644 --- a/application/src/main/data/json/system/scada_symbols/vertical-tank-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/vertical-tank-hp.svg @@ -38,7 +38,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 3;\n var majorIntervalLength = 994 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(160, y, 192, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n if (i === 0) {\n majorTickText.attr({x: 150, y: y + 10, 'text-anchor': 'end', class: 'majorTickText'});\n } else if (i === majorIntervals) {\n majorTickText.attr({x: 150, y: y - 5, 'text-anchor': 'end', class: 'majorTickText'});\n } else {\n majorTickText.attr({x: 150, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n }\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(172, minorY, 192, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 3;\n var majorIntervalLength = 994 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(160, y, 192, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n if (i === 0) {\n majorTickText.attr({x: 150, y: y + 10, 'text-anchor': 'end', class: 'majorTickText'});\n } else if (i === majorIntervals) {\n majorTickText.attr({x: 150, y: y - 5, 'text-anchor': 'end', class: 'majorTickText'});\n } else {\n majorTickText.attr({x: 150, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n }\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(172, minorY, 192, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -307,64 +307,67 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#EBEBEB", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#C8DFF7", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ], + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 10, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": false, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -377,96 +380,57 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks}", "type": "color", "default": "#00000061", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks}", "type": "color", "default": "#0000001F", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "warningColor", "name": "{i18n:scada.symbol.alarm-colors}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "criticalColor", "name": "{i18n:scada.symbol.alarm-colors}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/scada_symbols/vertical-tank.svg b/application/src/main/data/json/system/scada_symbols/vertical-tank.svg index de7f907e76..99fa9b649c 100644 --- a/application/src/main/data/json/system/scada_symbols/vertical-tank.svg +++ b/application/src/main/data/json/system/scada_symbols/vertical-tank.svg @@ -33,7 +33,7 @@ }, { "tag": "scale", - "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 205;\n var majorIntervalLength = 760 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(340, y, 372, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = (100 - i * (100/majorIntervals)).toFixed(0);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(352, minorY, 372, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", + "stateRenderFunction": "if (!ctx.properties.scale) {\n element.hide();\n} else {\n var scaleSet = element.remember('scaleSet');\n if (!scaleSet) {\n element.remember('scaleSet', true);\n element.clear();\n \n var majorIntervals = ctx.properties.majorIntervals;\n var minorIntervals = ctx.properties.minorIntervals;\n \n var start = 205;\n var majorIntervalLength = 760 / majorIntervals;\n var minorIntervalLength = majorIntervalLength / minorIntervals;\n var tankCapacity = ctx.properties.scaleDisplayFormat ? 100 : (ctx.values.tankCapacity || 100);\n for (var i = 0; i < majorIntervals + 1; i++) {\n var y = start + i * majorIntervalLength;\n var line = ctx.svg.line(340, y, 372, y).stroke({ width: 3 }).attr({class: 'majorTick'});\n element.add(line);\n var majorText = ctx.api.formatValue((tankCapacity - i * (tankCapacity/majorIntervals)).toFixed(0), 0, ctx.properties.majorUnits, false);\n var majorTickText = ctx.svg.text(majorText);\n majorTickText.attr({x: 330, y: y + 2, 'text-anchor': 'end', class: 'majorTickText'});\n majorTickText.first().attr({'dominant-baseline': 'middle'});\n element.add(majorTickText);\n if (i < majorIntervals) {\n drawMinorTicks(y, minorIntervals, minorIntervalLength);\n }\n }\n }\n \n var majorFont = ctx.properties.majorFont;\n var majorColor = ctx.properties.majorColor;\n var minorColor = ctx.properties.minorColor;\n if (ctx.values.critical) {\n majorColor = ctx.properties.majorCriticalColor;\n minorColor = ctx.properties.minorCriticalColor;\n } else if (ctx.values.warning) {\n majorColor = ctx.properties.minorWarningColor;\n minorColor = ctx.properties.minorWarningColor;\n }\n \n var majorTicks = element.find('line.majorTick');\n majorTicks.forEach(t => t.attr({stroke: majorColor}));\n \n var majorTicksText = element.find('text.majorTickText');\n ctx.api.font(majorTicksText, majorFont, majorColor);\n \n var minorTicks = element.find('line.minorTick');\n minorTicks.forEach(t => t.attr({stroke: minorColor}));\n \n var elementCriticalAnimation = element.remember('criticalAnimation');\n var criticalAnimation = ctx.values.critical && ctx.values.criticalAnimation;\n\n if (elementCriticalAnimation !== criticalAnimation) {\n element.remember('criticalAnimation', criticalAnimation);\n if (criticalAnimation) {\n ctx.api.cssAnimate(element, 500).attr({opacity: 0.15}).loop(0, true);\n } else {\n ctx.api.resetCssAnimation(element);\n }\n }\n}\n\nfunction drawMinorTicks(start, minorIntervals, minorIntervalLength) {\n for (var i = 1; i < minorIntervals; i++) {\n var minorY = start + i * minorIntervalLength;\n var minorLine = ctx.svg.line(352, minorY, 372, minorY).stroke({ width: 3 }).attr({class: 'minorTick'});\n element.add(minorLine);\n }\n}", "actions": null }, { @@ -278,80 +278,43 @@ "name": "{i18n:scada.symbol.tank-color}", "type": "color", "default": "#E5E5E5", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "fluidColor", "name": "{i18n:scada.symbol.fluid-color}", "type": "color", "default": "#1EC1F480", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBox", "name": "{i18n:scada.symbol.value-box}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueBoxColor", "name": "{i18n:scada.symbol.value-box}", "type": "color", "default": "#F3F3F3", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueUnits", "name": "{i18n:scada.symbol.value-text}", "type": "units", "default": "gal", - "required": null, "subLabel": "{i18n:scada.symbol.units}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextFont", @@ -364,80 +327,79 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "valueTextColor", "name": "{i18n:scada.symbol.value-text}", "type": "color", "default": "#0000008A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "valueBox", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "scale", "name": "{i18n:scada.symbol.scale}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "scaleDisplayFormat", + "name": "{i18n:scada.symbol.scale}", + "type": "select", + "default": true, + "subLabel": "{i18n:scada.symbol.display-format}", + "divider": false, + "disableOnProperty": "scale", + "items": [ + { + "value": true, + "label": "Percentage" + }, + { + "value": false, + "label": "Absolute" + } + ], + "disabled": false, + "visible": true }, { "id": "transparent", "name": "{i18n:scada.symbol.transparent-mode}", "type": "switch", "default": false, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorIntervals", "name": "{i18n:scada.symbol.major-ticks}", "type": "number", "default": 10, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": 1 + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "majorUnits", + "name": "{i18n:scada.symbol.major-ticks}", + "type": "units", + "subLabel": "{i18n:scada.symbol.units}", + "divider": true, + "disableOnProperty": "scale", + "disabled": false, + "visible": true }, { "id": "majorFont", @@ -450,128 +412,84 @@ "weight": "500", "style": "normal" }, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#00000061", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorWarningColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "majorCriticalColor", "name": "{i18n:scada.symbol.major-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorIntervals", "name": "{i18n:scada.symbol.minor-ticks}", "type": "number", "default": 5, - "required": null, "subLabel": "{i18n:scada.symbol.intervals}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", "min": 1, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#0000001F", - "required": null, "subLabel": "{i18n:scada.symbol.normal}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorWarningColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#FAA405", - "required": null, "subLabel": "{i18n:scada.symbol.warning}", "divider": true, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "minorCriticalColor", "name": "{i18n:scada.symbol.minor-ticks-color}", "type": "color", "default": "#D12730", - "required": null, "subLabel": "{i18n:scada.symbol.critical}", - "divider": null, - "fieldSuffix": null, "disableOnProperty": "scale", - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true } ] }]]> diff --git a/application/src/main/data/json/system/widget_bundles/maps.json b/application/src/main/data/json/system/widget_bundles/maps.json index 86754f0bd4..74b4870de1 100644 --- a/application/src/main/data/json/system/widget_bundles/maps.json +++ b/application/src/main/data/json/system/widget_bundles/maps.json @@ -2,13 +2,17 @@ "widgetsBundle": { "alias": "maps_v2", "title": "Maps", - "image": "data:image/png;base64,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", - "description": "Visualize the latest location or trip of the devices or other entities on the indoor or outdoor maps.", + "image": "tb-image:bWFwcy13aWRnZXQtYnVuZGxlLnBuZw==:Ik1hcHMiIHN5c3RlbSBidW5kbGUgaW1hZ2U=:SU1BR0U=;data:image/png;base64,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", + "scada": false, + "description": "Visualize the latest location or trip of devices and entities on indoor and outdoor maps using markers, polygons, and circles for enhanced spatial representation.", "order": 6000, - "externalId": null, "name": "Maps" }, "widgetTypeFqns": [ + "map", + "image_map", + "trip_map", + "route_map", "maps_v2.openstreetmap", "maps_v2.google_maps", "maps_v2.image_map", diff --git a/application/src/main/data/json/system/widget_types/google_map.json b/application/src/main/data/json/system/widget_types/google_map.json index 9c7696d8db..cd69b304e9 100644 --- a/application/src/main/data/json/system/widget_types/google_map.json +++ b/application/src/main/data/json/system/widget_types/google_map.json @@ -1,7 +1,7 @@ { "fqn": "maps_v2.google_maps", "name": "Google Map", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/google_map_system_widget_image.png", "description": "Displays the location of the entities on Google Maps. Requires the Google map key to work properly. Highly customizable via custom markers, marker tooltips, and widget actions.", "descriptor": { @@ -14,7 +14,7 @@ "controllerScript": "self.onInit = function() {\n self.ctx.map = new TbMapWidgetV2('google-map', false, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", "settingsSchema": "", "dataKeySettingsSchema": "", - "settingsDirective": "tb-map-widget-settings", + "settingsDirective": "tb-map-widget-settings-legacy", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.05427416942713381,\"funcBody\":\"var value = prevValue || 15.833293;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.680594833308841,\"funcBody\":\"var value = prevValue || -90.454350;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.9430343126300238,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.1784452363910778,\"funcBody\":\"return \\\"colorpin\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]},{\"type\":\"function\",\"name\":\"Second point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.05012157428742059,\"funcBody\":\"var value = prevValue || 14.450463;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.6742359401617628,\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.773875863339494,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#3f51b5\",\"settings\":{},\"_hash\":0.405822538899673,\"funcBody\":\"return \\\"thermometer\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"provider\":\"google-map\",\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"gmDefaultMapType\":\"roadmap\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"xPosKeyName\":\"xPos\",\"yPosKeyName\":\"yPos\",\"defaultCenterPosition\":\"0,0\",\"disableScrollZooming\":false,\"disableDoubleClickZooming\":false,\"disableZoomControl\":false,\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"mapPageSize\":16384,\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"posFunction\":\"return {x: origXPos, y: origYPos};\",\"draggableMarker\":false,\"showLabel\":true,\"useLabelFunction\":false,\"label\":\"${entityName}\",\"showTooltip\":true,\"showTooltipAction\":\"click\",\"autocloseTooltip\":true,\"useTooltipFunction\":false,\"tooltipPattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See advanced settings for details\",\"tooltipOffsetX\":0,\"tooltipOffsetY\":-1,\"color\":\"#fe7568\",\"useColorFunction\":true,\"colorFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'colorpin') {\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120 * 100;\\n\\t return tinycolor.mix('blue', 'red', percent).toHexString();\\n\\t}\\n\\treturn 'blue';\\n}\\n\",\"useMarkerImageFunction\":true,\"markerImageSize\":34,\"markerImageFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'thermometer') {\\n\\tvar res = {\\n\\t url: images[0],\\n\\t size: 40\\n\\t}\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120;\\n\\t var index = Math.min(3, Math.floor(4 * percent));\\n\\t res.url = images[index];\\n\\t}\\n\\treturn res;\\n}\",\"markerImages\":[\"tb-image;/api/images/system/map_marker_image_0.png\",\"tb-image;/api/images/system/map_marker_image_1.png\",\"tb-image;/api/images/system/map_marker_image_2.png\",\"tb-image;/api/images/system/map_marker_image_3.png\"],\"showPolygon\":false,\"polygonKeyName\":\"perimeter\",\"editablePolygon\":false,\"showPolygonLabel\":false,\"usePolygonLabelFunction\":false,\"polygonLabel\":\"${entityName}\",\"showPolygonTooltip\":false,\"showPolygonTooltipAction\":\"click\",\"autoClosePolygonTooltip\":true,\"usePolygonTooltipFunction\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"polygonColor\":\"#3388ff\",\"polygonOpacity\":0.2,\"usePolygonColorFunction\":false,\"polygonStrokeColor\":\"#3388ff\",\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":3,\"usePolygonStrokeColorFunction\":false,\"showCircle\":false,\"circleKeyName\":\"perimeter\",\"editableCircle\":false,\"showCircleLabel\":false,\"useCircleLabelFunction\":false,\"circleLabel\":\"${entityName}\",\"showCircleTooltip\":false,\"showCircleTooltipAction\":\"click\",\"autoCloseCircleTooltip\":true,\"useCircleTooltipFunction\":false,\"circleTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"circleFillColor\":\"#3388ff\",\"circleFillColorOpacity\":0.2,\"useCircleFillColorFunction\":false,\"circleStrokeColor\":\"#3388ff\",\"circleStrokeOpacity\":1,\"circleStrokeWeight\":3,\"useCircleStrokeColorFunction\":false,\"useClusterMarkers\":false,\"zoomOnClick\":true,\"maxClusterRadius\":80,\"animate\":true,\"spiderfyOnMaxZoom\":false,\"showCoverageOnHover\":true,\"chunkedLoading\":false,\"removeOutsideVisibleBounds\":true,\"useIconCreateFunction\":false},\"title\":\"Google Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}" }, "tags": [ diff --git a/application/src/main/data/json/system/widget_types/here_map.json b/application/src/main/data/json/system/widget_types/here_map.json index 9f5d7e9812..b49e1dcf41 100644 --- a/application/src/main/data/json/system/widget_types/here_map.json +++ b/application/src/main/data/json/system/widget_types/here_map.json @@ -1,7 +1,7 @@ { "fqn": "maps_v2.here_map", "name": "HERE Map", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/here_map_system_widget_image.png", "description": "Displays the location of the entities on HERE Maps. Requires the HERE map key to work properly. Highly customizable via custom markers, marker tooltips, and widget actions.", "descriptor": { @@ -14,7 +14,7 @@ "controllerScript": "self.onInit = function() {\n self.ctx.map = new TbMapWidgetV2('here', false, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", "settingsSchema": "", "dataKeySettingsSchema": "", - "settingsDirective": "tb-map-widget-settings", + "settingsDirective": "tb-map-widget-settings-legacy", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.05427416942713381,\"funcBody\":\"var value = prevValue || 15.833293;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.680594833308841,\"funcBody\":\"var value = prevValue || -90.454350;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.9430343126300238,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.1784452363910778,\"funcBody\":\"return \\\"colorpin\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]},{\"type\":\"function\",\"name\":\"Second point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.05012157428742059,\"funcBody\":\"var value = prevValue || 14.450463;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.6742359401617628,\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.773875863339494,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#3f51b5\",\"settings\":{},\"_hash\":0.405822538899673,\"funcBody\":\"return \\\"thermometer\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"provider\":\"here\",\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"gmDefaultMapType\":\"roadmap\",\"mapProvider\":\"HERE.normalDay\",\"useCustomProvider\":false,\"customProviderTileUrl\":\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\",\"mapProviderHere\":\"HERE.normalDay\",\"credentials\":{\"useV3\":true,\"apiKey\":\"kVXykxAfZ6LS4EbCTO02soFVfjA7HoBzNVVH9u7nzoE\"},\"mapImageUrl\":\"tb-image;/api/images/system/here_map_system_widget_map_image.svg\",\"tmApiKey\":\"84d6d83e0e51e481e50454ccbe8986b\",\"tmDefaultMapType\":\"roadmap\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"xPosKeyName\":\"xPos\",\"yPosKeyName\":\"yPos\",\"defaultCenterPosition\":\"0,0\",\"disableScrollZooming\":false,\"disableDoubleClickZooming\":false,\"disableZoomControl\":false,\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"mapPageSize\":16384,\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"posFunction\":\"return {x: origXPos, y: origYPos};\",\"draggableMarker\":false,\"showLabel\":true,\"useLabelFunction\":false,\"label\":\"${entityName}\",\"showTooltip\":true,\"showTooltipAction\":\"click\",\"autocloseTooltip\":true,\"useTooltipFunction\":false,\"tooltipPattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See advanced settings for details\",\"tooltipOffsetX\":0,\"tooltipOffsetY\":-1,\"color\":\"#fe7569\",\"useColorFunction\":true,\"colorFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'colorpin') {\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120 * 100;\\n\\t return tinycolor.mix('blue', 'red', percent).toHexString();\\n\\t}\\n\\treturn 'blue';\\n}\\n\",\"useMarkerImageFunction\":true,\"markerImageSize\":34,\"markerImageFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'thermometer') {\\n\\tvar res = {\\n\\t url: images[0],\\n\\t size: 40\\n\\t}\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120;\\n\\t var index = Math.min(3, Math.floor(4 * percent));\\n\\t res.url = images[index];\\n\\t}\\n\\treturn res;\\n}\",\"markerImages\":[\"tb-image;/api/images/system/map_marker_image_0.png\",\"tb-image;/api/images/system/map_marker_image_1.png\",\"tb-image;/api/images/system/map_marker_image_2.png\",\"tb-image;/api/images/system/map_marker_image_3.png\"],\"showPolygon\":false,\"polygonKeyName\":\"coordinates\",\"editablePolygon\":false,\"showPolygonLabel\":false,\"usePolygonLabelFunction\":false,\"polygonLabel\":\"${entityName}\",\"showPolygonTooltip\":false,\"showPolygonTooltipAction\":\"click\",\"autoClosePolygonTooltip\":true,\"usePolygonTooltipFunction\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"polygonColor\":\"#3388ff\",\"polygonOpacity\":0.5,\"usePolygonColorFunction\":false,\"polygonStrokeColor\":\"#3388ff\",\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":1,\"usePolygonStrokeColorFunction\":false,\"showCircle\":false,\"circleKeyName\":\"perimeter\",\"editableCircle\":false,\"showCircleLabel\":false,\"useCircleLabelFunction\":false,\"circleLabel\":\"${entityName}\",\"showCircleTooltip\":false,\"showCircleTooltipAction\":\"click\",\"autoCloseCircleTooltip\":true,\"useCircleTooltipFunction\":false,\"circleTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"circleFillColor\":\"#3388ff\",\"circleFillColorOpacity\":0.2,\"useCircleFillColorFunction\":false,\"circleStrokeColor\":\"#3388ff\",\"circleStrokeOpacity\":1,\"circleStrokeWeight\":3,\"useCircleStrokeColorFunction\":false,\"useClusterMarkers\":false,\"zoomOnClick\":true,\"maxClusterRadius\":80,\"animate\":true,\"spiderfyOnMaxZoom\":false,\"showCoverageOnHover\":true,\"chunkedLoading\":false,\"removeOutsideVisibleBounds\":true,\"useIconCreateFunction\":false},\"title\":\"HERE Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}" }, "tags": [ diff --git a/application/src/main/data/json/system/widget_types/horizontal_ellipse_tank.json b/application/src/main/data/json/system/widget_types/horizontal_ellipse_tank.json index 6e7d86b847..441a5a520c 100644 --- a/application/src/main/data/json/system/widget_types/horizontal_ellipse_tank.json +++ b/application/src/main/data/json/system/widget_types/horizontal_ellipse_tank.json @@ -17,7 +17,7 @@ "settingsDirective": "tb-liquid-level-card-widget-settings", "hasBasicMode": true, "basicModeDirective": "tb-liquid-level-card-basic-config", - "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"return Math.floor(Math.random() * 101);\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"alarmFilterConfig\":{\"statusList\":[\"ACTIVE\"]}}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"tankSelectionType\":\"Static\",\"selectedShape\":\"Horizontal Ellipse\",\"shapeAttributeName\":\"tankShape\",\"tankColor\":{\"type\":\"range\",\"color\":\"#242770\",\"rangeList\":[{\"from\":null,\"to\":20,\"color\":\"#E73535DE\"},{\"from\":20,\"to\":null,\"color\":\"#242770\"}],\"colorFunction\":\"var percent = value;\\nif (typeof percent !== undefined) {\\n if (percent < 20) {\\n return '#E73535DE';\\n }\\n}\\nreturn '#242770';\"},\"datasourceUnits\":\"%\",\"layout\":\"percentage\",\"volumeSource\":\"static\",\"volumeConstant\":500,\"volumeAttributeName\":\"volume\",\"volumeUnits\":\"L\",\"volumeFont\":{\"family\":\"Roboto\",\"size\":14,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"500\",\"lineHeight\":\"100%\"},\"volumeColor\":\"rgba(0, 0, 0, 0.18)\",\"units\":\"%\",\"widgetUnitsSource\":\"static\",\"widgetUnitsAttributeName\":\"units\",\"liquidColor\":{\"type\":\"range\",\"color\":\"#7A8BFF\",\"rangeList\":[{\"from\":null,\"to\":20,\"color\":\"#E27C7CDE\"},{\"from\":20,\"to\":null,\"color\":\"#7A8BFF\"}],\"colorFunction\":\"var percent = value;\\nif (typeof percent !== undefined) {\\n if (percent < 20) {\\n return '#E27C7CDE';\\n }\\n}\\nreturn '#7A8BFF';\"},\"valueFont\":{\"family\":\"Roboto\",\"size\":24,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"500\",\"lineHeight\":\"100%\"},\"valueColor\":{\"type\":\"range\",\"color\":\"#000000DE\",\"rangeList\":[{\"from\":null,\"to\":20,\"color\":\"#FF0000DE\"},{\"from\":20,\"to\":null,\"color\":\"rgba(0,0,0,0.87)\"}],\"colorFunction\":\"var percent = value;\\nif (typeof percent !== undefined) {\\n if (percent < 20) {\\n return '#FF0000DE';\\n }\\n}\\nreturn '#000000DE';\"},\"showBackgroundOverlay\":true,\"backgroundOverlayColor\":{\"type\":\"range\",\"color\":\"#FFFFFFC2\",\"rangeList\":[{\"from\":0,\"to\":20,\"color\":\"#FFEFEFDE\"},{\"from\":20,\"to\":null,\"color\":\"#FFFFFFC2\"}],\"colorFunction\":\"var percent = value;\\nif (typeof percent !== undefined) {\\n if (percent < 20) {\\n return '#FFEFEFDE';\\n }\\n}\\nreturn '#FFFFFFC2';\"},\"showTooltip\":true,\"showTooltipLevel\":true,\"tooltipUnits\":\"%\",\"tooltipLevelDecimals\":0,\"tooltipLevelFont\":{\"family\":\"Roboto\",\"size\":13,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"500\",\"lineHeight\":\"100%\"},\"tooltipLevelColor\":{\"type\":\"constant\",\"color\":\"rgba(0, 0, 0, 0.76)\",\"rangeList\":[],\"colorFunction\":\"var percent = value;\\nif (typeof percent !== undefined) {\\n if (percent < 20) {\\n return '#E27C7CDE';\\n }\\n}\\nreturn '#7A8BFF';\"},\"showTooltipDate\":true,\"tooltipDateFormat\":{\"format\":null,\"lastUpdateAgo\":true,\"custom\":false},\"tooltipDateFont\":{\"family\":\"Roboto\",\"size\":13,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"500\",\"lineHeight\":\"100%\"},\"tooltipDateColor\":\"rgba(0, 0, 0, 0.76)\",\"tooltipBackgroundColor\":\"rgba(255, 255, 255, 0.76)\",\"tooltipBackgroundBlur\":3,\"background\":{\"type\":\"color\",\"color\":\"#fff\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}}},\"title\":\"Liquid level\",\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"configMode\":\"basic\",\"titleFont\":{\"family\":\"Roboto\",\"size\":16,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"500\",\"lineHeight\":\"1.5\"},\"titleColor\":\"rgba(0, 0, 0, 0.87)\",\"showTitleIcon\":false,\"titleIcon\":\"water_drop\",\"iconColor\":\"#5469FF\",\"decimals\":0,\"enableDataExport\":false,\"enableFullscreen\":false,\"borderRadius\":\"0px\",\"actions\":{},\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"margin\":\"0px\",\"widgetStyle\":{},\"widgetCss\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"noDataDisplayMessage\":\"\"}" + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"return Math.floor(Math.random() * 101);\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"alarmFilterConfig\":{\"statusList\":[\"ACTIVE\"]}}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"tankSelectionType\":\"static\",\"selectedShape\":\"Horizontal Ellipse\",\"shapeAttributeName\":\"tankShape\",\"tankColor\":{\"type\":\"range\",\"color\":\"#242770\",\"rangeList\":[{\"from\":null,\"to\":20,\"color\":\"#E73535DE\"},{\"from\":20,\"to\":null,\"color\":\"#242770\"}],\"colorFunction\":\"var percent = value;\\nif (typeof percent !== undefined) {\\n if (percent < 20) {\\n return '#E73535DE';\\n }\\n}\\nreturn '#242770';\"},\"datasourceUnits\":\"%\",\"layout\":\"percentage\",\"volumeSource\":\"static\",\"volumeConstant\":500,\"volumeAttributeName\":\"volume\",\"volumeUnits\":\"L\",\"volumeFont\":{\"family\":\"Roboto\",\"size\":14,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"500\",\"lineHeight\":\"100%\"},\"volumeColor\":\"rgba(0, 0, 0, 0.18)\",\"units\":\"%\",\"widgetUnitsSource\":\"static\",\"widgetUnitsAttributeName\":\"units\",\"liquidColor\":{\"type\":\"range\",\"color\":\"#7A8BFF\",\"rangeList\":[{\"from\":null,\"to\":20,\"color\":\"#E27C7CDE\"},{\"from\":20,\"to\":null,\"color\":\"#7A8BFF\"}],\"colorFunction\":\"var percent = value;\\nif (typeof percent !== undefined) {\\n if (percent < 20) {\\n return '#E27C7CDE';\\n }\\n}\\nreturn '#7A8BFF';\"},\"valueFont\":{\"family\":\"Roboto\",\"size\":24,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"500\",\"lineHeight\":\"100%\"},\"valueColor\":{\"type\":\"range\",\"color\":\"#000000DE\",\"rangeList\":[{\"from\":null,\"to\":20,\"color\":\"#FF0000DE\"},{\"from\":20,\"to\":null,\"color\":\"rgba(0,0,0,0.87)\"}],\"colorFunction\":\"var percent = value;\\nif (typeof percent !== undefined) {\\n if (percent < 20) {\\n return '#FF0000DE';\\n }\\n}\\nreturn '#000000DE';\"},\"showBackgroundOverlay\":true,\"backgroundOverlayColor\":{\"type\":\"range\",\"color\":\"#FFFFFFC2\",\"rangeList\":[{\"from\":0,\"to\":20,\"color\":\"#FFEFEFDE\"},{\"from\":20,\"to\":null,\"color\":\"#FFFFFFC2\"}],\"colorFunction\":\"var percent = value;\\nif (typeof percent !== undefined) {\\n if (percent < 20) {\\n return '#FFEFEFDE';\\n }\\n}\\nreturn '#FFFFFFC2';\"},\"showTooltip\":true,\"showTooltipLevel\":true,\"tooltipUnits\":\"%\",\"tooltipLevelDecimals\":0,\"tooltipLevelFont\":{\"family\":\"Roboto\",\"size\":13,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"500\",\"lineHeight\":\"100%\"},\"tooltipLevelColor\":{\"type\":\"constant\",\"color\":\"rgba(0, 0, 0, 0.76)\",\"rangeList\":[],\"colorFunction\":\"var percent = value;\\nif (typeof percent !== undefined) {\\n if (percent < 20) {\\n return '#E27C7CDE';\\n }\\n}\\nreturn '#7A8BFF';\"},\"showTooltipDate\":true,\"tooltipDateFormat\":{\"format\":null,\"lastUpdateAgo\":true,\"custom\":false},\"tooltipDateFont\":{\"family\":\"Roboto\",\"size\":13,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"500\",\"lineHeight\":\"100%\"},\"tooltipDateColor\":\"rgba(0, 0, 0, 0.76)\",\"tooltipBackgroundColor\":\"rgba(255, 255, 255, 0.76)\",\"tooltipBackgroundBlur\":3,\"background\":{\"type\":\"color\",\"color\":\"#fff\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}}},\"title\":\"Liquid level\",\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"configMode\":\"basic\",\"titleFont\":{\"family\":\"Roboto\",\"size\":16,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"500\",\"lineHeight\":\"1.5\"},\"titleColor\":\"rgba(0, 0, 0, 0.87)\",\"showTitleIcon\":false,\"titleIcon\":\"water_drop\",\"iconColor\":\"#5469FF\",\"decimals\":0,\"enableDataExport\":false,\"enableFullscreen\":false,\"borderRadius\":\"0px\",\"actions\":{},\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"margin\":\"0px\",\"widgetStyle\":{},\"widgetCss\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"noDataDisplayMessage\":\"\"}" }, "tags": [ "reservoir", diff --git a/application/src/main/data/json/system/widget_types/image_map.json b/application/src/main/data/json/system/widget_types/image_map.json index 02554e82f6..2a4fe71696 100644 --- a/application/src/main/data/json/system/widget_types/image_map.json +++ b/application/src/main/data/json/system/widget_types/image_map.json @@ -1,46 +1,34 @@ { - "fqn": "maps_v2.image_map", + "fqn": "image_map", "name": "Image Map", "deprecated": false, - "image": "tb-image;/api/images/system/image_map_system_widget_image.png", - "description": "Displays the indoor or relative location of the entities on the image map. Useful to display floor maps, smart parking, etc. Entity coordinates are expected to be in the range from 0 to 1. Highly customizable via custom markers, marker tooltips, and widget actions. ", + "image": "tb-image;/api/images/system/image-map-widget.png", + "description": "Displays the indoor or relative location of entities on an image map, making it ideal for floor plans, smart parking, and more. Entity coordinates are expected to range from 0 to 1. Supports markers, marker tooltips, widget actions, polygons, and circles for enhanced spatial representation.", "descriptor": { "type": "latest", "sizeX": 8.5, - "sizeY": 6.5, + "sizeY": 6, "resources": [], - "templateHtml": "", - "templateCss": ".leaflet-zoom-box {\n\tz-index: 9;\n}\n\n.leaflet-pane { z-index: 4; }\n\n.leaflet-tile-pane { z-index: 2; }\n.leaflet-overlay-pane { z-index: 4; }\n.leaflet-shadow-pane { z-index: 5; }\n.leaflet-marker-pane { z-index: 6; }\n.leaflet-tooltip-pane { z-index: 7; }\n.leaflet-popup-pane { z-index: 8; }\n\n.leaflet-map-pane canvas { z-index: 1; }\n.leaflet-map-pane svg { z-index: 2; }\n\n.leaflet-control {\n\tz-index: 9;\n}\n.leaflet-top,\n.leaflet-bottom {\n\tz-index: 11;\n}\n\n.tb-marker-label {\n border: none;\n background: none;\n box-shadow: none;\n}\n\n.tb-marker-label:before {\n border: none;\n background: none;\n}\n", - "controllerScript": "self.onInit = function() {\n self.ctx.map = new TbMapWidgetV2('image-map', false, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", - "settingsSchema": "", - "dataKeySettingsSchema": "", + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true,\n additionalWidgetActionTypes: ['placeMapItem']\n };\n}\n", + "settingsForm": [], + "dataKeySettingsForm": [], "settingsDirective": "tb-map-widget-settings", - "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"xPos\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.05427416942713381,\"funcBody\":\"var value = prevValue || 0.2;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"yPos\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.680594833308841,\"funcBody\":\"var value = prevValue || 0.3;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.9430343126300238,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.1784452363910778,\"funcBody\":\"return \\\"colorpin\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]},{\"type\":\"function\",\"name\":\"Second point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"xPos\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.05012157428742059,\"funcBody\":\"var value = prevValue || 0.6;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"yPos\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.6742359401617628,\"funcBody\":\"var value = prevValue || 0.7;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.773875863339494,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#3f51b5\",\"settings\":{},\"_hash\":0.405822538899673,\"funcBody\":\"return \\\"thermometer\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"provider\":\"image-map\",\"mapImageUrl\":\"tb-image;/api/images/system/image_map_system_widget_map_image.svg\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"xPosKeyName\":\"xPos\",\"yPosKeyName\":\"yPos\",\"defaultCenterPosition\":\"0,0\",\"disableScrollZooming\":false,\"disableDoubleClickZooming\":false,\"disableZoomControl\":false,\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"mapPageSize\":16384,\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"posFunction\":\"return {x: origXPos, y: origYPos};\",\"draggableMarker\":false,\"showLabel\":true,\"useLabelFunction\":false,\"label\":\"${entityName}\",\"showTooltip\":true,\"showTooltipAction\":\"click\",\"autocloseTooltip\":true,\"useTooltipFunction\":false,\"tooltipPattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See advanced settings for details\",\"tooltipOffsetX\":0,\"tooltipOffsetY\":-1,\"color\":\"#fe7569\",\"useColorFunction\":true,\"colorFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'colorpin') {\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120 * 100;\\n\\t return tinycolor.mix('blue', 'red', percent).toHexString();\\n\\t}\\n\\treturn 'blue';\\n}\\n\",\"useMarkerImageFunction\":true,\"markerImageSize\":34,\"markerImageFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'thermometer') {\\n\\tvar res = {\\n\\t url: images[0],\\n\\t size: 40\\n\\t}\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120;\\n\\t var index = Math.min(3, Math.floor(4 * percent));\\n\\t res.url = images[index];\\n\\t}\\n\\treturn res;\\n}\",\"markerImages\":[\"tb-image;/api/images/system/map_marker_image_0.png\",\"tb-image;/api/images/system/map_marker_image_1.png\",\"tb-image;/api/images/system/map_marker_image_2.png\",\"tb-image;/api/images/system/map_marker_image_3.png\"],\"showPolygon\":false,\"polygonKeyName\":\"perimeter\",\"editablePolygon\":false,\"showPolygonLabel\":false,\"usePolygonLabelFunction\":false,\"polygonLabel\":\"${entityName}\",\"showPolygonTooltip\":false,\"showPolygonTooltipAction\":\"click\",\"autoClosePolygonTooltip\":true,\"usePolygonTooltipFunction\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"polygonColor\":\"#3388ff\",\"polygonOpacity\":0.2,\"usePolygonColorFunction\":false,\"polygonStrokeColor\":\"#3388ff\",\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":3,\"usePolygonStrokeColorFunction\":false,\"showCircle\":false,\"circleKeyName\":\"perimeter\",\"editableCircle\":false,\"showCircleLabel\":false,\"useCircleLabelFunction\":false,\"circleLabel\":\"${entityName}\",\"showCircleTooltip\":false,\"showCircleTooltipAction\":\"click\",\"autoCloseCircleTooltip\":true,\"useCircleTooltipFunction\":false,\"circleTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"circleFillColor\":\"#3388ff\",\"circleFillColorOpacity\":0.2,\"useCircleFillColorFunction\":false,\"circleStrokeColor\":\"#3388ff\",\"circleStrokeOpacity\":1,\"circleStrokeWeight\":3,\"useCircleStrokeColorFunction\":false},\"title\":\"Image Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}" + "hasBasicMode": true, + "basicModeDirective": "tb-map-basic-config", + "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"mapType\":\"image\",\"imageSource\":{\"sourceType\":\"image\",\"url\":\"tb-image;/api/images/system/image_map_system_widget_map_image.svg\",\"entityAliasId\":null,\"entityKey\":null},\"markers\":[{\"dsType\":\"function\",\"dsLabel\":\"First point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.8239425680406081,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See tooltip settings for details\",\"patternFunction\":null,\"trigger\":\"click\",\"autoclose\":true,\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.2;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.3;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"shape\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"function\",\"color\":\"#307FE5\",\"colorFunction\":\"var temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\\n\"}},\"markerIcon\":{\"icon\":\"mdi:lightbulb-on\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"image\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xOTEuMzUgLTM1MS4xOCAxMDgzLjU4IDE3MzAuNDYiPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjZmU3NTY5IiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMzciIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTM1MS44MzMgMTM2MC43OGMtMzguNzY2LTE5MC4zLTEwNy4xMTYtMzQ4LjY2NS0xODkuOTAzLTQ5NS40NEMxMDAuNTIzIDc1Ni40NjkgMjkuMzg2IDY1NS45NzgtMzYuNDM0IDU1MC40MDRjLTIxLjk3Mi0zNS4yNDQtNDAuOTM0LTcyLjQ3Ny02Mi4wNDctMTA5LjA1NC00Mi4yMTYtNzMuMTM3LTc2LjQ0NC0xNTcuOTM1LTc0LjI2OS0yNjcuOTMyIDIuMTI1LTEwNy40NzMgMzMuMjA4LTE5My42ODUgNzguMDMtMjY0LjE3M0MtMjEtMjA2LjY5IDEwMi40ODEtMzAxLjc0NSAyNjguMTY0LTMyNi43MjRjMTM1LjQ2Ni0yMC40MjUgMjYyLjQ3NSAxNC4wODIgMzUyLjU0MyA2Ni43NDcgNzMuNiA0My4wMzggMTMwLjU5NiAxMDAuNTI4IDE3My45MiAxNjguMjggNDUuMjIgNzAuNzE2IDc2LjM2IDE1NC4yNiA3OC45NzEgMjYzLjIzMyAxLjMzNyA1NS44My03LjgwNSAxMDcuNTMyLTIwLjY4NCAxNTAuNDE3LTEzLjAzNCA0My40MS0zMy45OTYgNzkuNjk1LTUyLjY0NiAxMTguNDU1LTM2LjQwNiA3NS42NTktODIuMDQ5IDE0NC45ODEtMTI3Ljg1NSAyMTQuMzQ1LTEzNi40MzcgMjA2LjYwNi0yNjQuNDk2IDQxNy4zMS0zMjAuNTggNzA2LjAyOHoiLz48Y2lyY2xlIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBjeD0iMzUyLjg5MSIgY3k9IjIyNS43NzkiIHI9IjE4My4zMzIiLz48L3N2Zz4=\",\"imageSize\":34},\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"positionFunction\":\"return {x: origXPos, y: origYPos};\",\"markerClustering\":{\"enable\":false,\"zoomOnClick\":true,\"maxZoom\":null,\"maxClusterRadius\":80,\"zoomAnimation\":true,\"showCoverageOnHover\":true,\"spiderfyOnMaxZoom\":false,\"chunkedLoad\":false,\"lazyLoad\":true,\"useClusterMarkerColorFunction\":false,\"clusterMarkerColorFunction\":null}},{\"dsType\":\"function\",\"dsLabel\":\"Second point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7826299113906372,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"trigger\":\"click\",\"autoclose\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See tooltip settings for details\",\"offsetX\":0,\"offsetY\":-1,\"patternFunction\":null},\"click\":{\"type\":\"doNothing\"},\"groups\":null,\"edit\":{\"enabledActions\":[],\"snappable\":false},\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.6;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.7;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"icon\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerIcon\":{\"size\":40,\"color\":{\"type\":\"function\",\"color\":\"#307FE5\",\"colorFunction\":\"var colors = ['#488bc7','#549c5d','#ed7546','#be2b29'];\\nvar temperature = data.temperature;\\nvar res = colors[0];\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120;\\n var index = Math.min(3, Math.floor(4 * percent));\\n res = colors[index];\\n}\\nreturn res;\"},\"icon\":\"thermostat\"},\"markerImage\":{\"type\":\"function\",\"image\":\"/assets/markers/shape1.svg\",\"imageSize\":34,\"imageFunction\":\" \",\"images\":[]},\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"positionFunction\":\"return {x: origXPos, y: origYPos};\",\"markerClustering\":{\"enable\":false,\"zoomOnClick\":true,\"maxZoom\":null,\"maxClusterRadius\":80,\"zoomAnimation\":true,\"showCoverageOnHover\":true,\"spiderfyOnMaxZoom\":false,\"chunkedLoad\":false,\"lazyLoad\":true,\"useClusterMarkerColorFunction\":false,\"clusterMarkerColorFunction\":null}}],\"polygons\":[],\"circles\":[],\"additionalDataSources\":[]},\"title\":\"Image Map\",\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showTitleIcon\":false,\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"widgetCss\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"24px\",\"titleIcon\":\"map\",\"iconColor\":\"#1F6BDD\",\"actions\":{}}" }, - "tags": [ - "building", - "interior", - "venue", - "inside", - "room", - "office", - "manufacturing", - "floor", - "plant", - "storage", - "warehouse", - "depot" - ], "resources": [ { - "link": "/api/images/system/image_map_system_widget_image.png", + "link": "/api/images/system/image-map-widget.png", "title": "\"Image Map\" system widget image", "type": "IMAGE", "subType": "IMAGE", - "fileName": "image_map_system_widget_image.png", - "publicResourceKey": "hDdSISQr6elribOYD6T3uePXZI5WvNtM", + "fileName": "image-map-widget.png", + "publicResourceKey": "UJKMEwPHCOL8rQk6qr9pRRHNnD90yeTA", "mediaType": "image/png", - "data": "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", + "data": "iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAC8VBMVEUAAAAyMmU1NWUAAAAzM2YAAAAAAAAyMmQtLWg0NGkAAAAzM2YzM2YAAAAzPWEAAAE2TlkyMmQxPF0zPGADAQE2PmM1TFoyMmYBAQIAAAAyMmU2TlkzM2U2TFo2TlkAAAAAAAAAAAA2Tlk1TVk2Tlg0NGYAAAEyMmYyMmQyMmUAAAAAAAAAAAEzOWLMhA02TlkAAAABAADIfg42TlkBAQE2Tlk2Tlk2Tlk2TlkyNWQAAAEzM2cAAAAAAAA2TlkAAAE4Tlo1Tlg0PV82TVk1TVgzNGQyMmU2TVg2TVgBAQK1tbUyMmbKyso1TVc1TFg2TlkAAAI1TlkDAwU2Tlk2TVk2TVcyMmU2Tlg2TlkBAgI2TlkAAAAyMmMzM2UBAQI1TlkxMmU2Tlk2Tlm8vLwyMmUzNGU2TVk1TlmlKjM2TVk1TFc1TVk3UFk2Tlk1TVc3TlnHx8eysrLVbyq9vb3YkQwzM2U1Tlmrq6s0Ql4ZGDE1TFjMgg3RiQ3fmQspKVMgHS80NGjLy8upLDXXkAw2TlkZGy0hIz8jI0ffmwc1RV00Q100QF8AAAAqKlOtLTc0RV00QF8mKEkzM2fiiRGxLzq9vb3Vjgy/ND8BAQPclwuwsLEgFijAwMC8vLyxMTzUkA80PmEZHDLFNkLGN0LfmQsaGjfIOELKhA4tQUsfHz4ODhvJN0Himg42Tlm3MTuvsLSsLjiqNEPCgw/CNj6WLjweER+BMEuwsLBZMQ2ysre5Mjy7MjwsP0ardAmTmJuoeCdqTBluZ1ynq62baxG1trYjMjqJXwjCNUDdmAxfTk4YGDFrV0tuM1r/////wQf/TVrOzs7c3NyYmJigKDF1dXXr6+v8vgi6urqGhoa7Mj3ysQj3SVbVjgx+fn7ZkgzhnArz8/PFeg7HfA7urAn19fXk5OSlpaWfn5/oRFDgQUzqpwqVlZXCeA/uRlPaPkrv7+/U1NS1tbXSO0bPhw34uAivLjf39/eQkJB3d3fGN0LFNkExZ09+AAAAzXRSTlMATEQFVQweMxEhPUlPCAcvZUcKFEMOIlFGKTndK3fCVzs5vJCJUxZCPBsaSUEX9cxONf7aD+CqlUVwaD8yXFQSEXIetZp7LyrWUywo656CfmE7IaRQMySvXiR6d16Ib2lZWEDBZVdNMPzHbtEb5Eo3724Z2KyBJv68eEL+5FlWRjH8+L24cmdbJuTGlX9O8dinZDIL4NnMqYt0cVH+1rufiINxZUY6Lu7eb11NNe7V1NLIw5SPjnFuWdHNyLKVlJSHfeXUyL6pj2pmXlhBzkWSrgAAGPRJREFUeNrVnWdc20YYxiVjsLEdb7ANxsaYbQMGh2X2hlI2hBlW2dCku+lMZ9K999577713S5tCQwNt2jRpku69P/U0jIYlSzIubZ/8YovT3en+un2vdIaEC+Y+FW4mOR5lYfSrzT3Lx02j5L5amsQ3mDAA4mqcIOVkECibYBqaKlN3FefltatzoxsVzCkSDgIHBhKl4wapJrtaIrHv8cnJ+JiymHh7ZYO9qbgUggMC0QUNxBzOCZJCAcktRr/KalsongMFiQoWSHm1QBB1E/iYqWiL4Ly+fFVBpMncICeRXTd2guyI7oKg/xgIJOcGoZRjrXvDdG4pFCQQafBAYI4z9Jbl2HsrxiHofw8yZomb3Aj9T0GWm0itejqnA2pUBxMkeTVBorD+z55tR/qQYvtqg4QgChZIcVyGWutvkAIzuHA7RXCChEBXHXTgyQcedCjEwQKXHqsui1G3D9eUssQojdL1uNuI/i97FUFC1hxw6fZZRNsPP2CNP5TIs2obeqYGmuxtFdMHZ+Q0qofL6TGObXL3kAcytdpVAwEch8x6dYg/khr3keQhXEtXY9tktvu0yniQR1V5eWPqxorsihhqmIrxIIIU+PWzBrp0ltDh4G82ZQwzXE5bkzcE8mhTTmVuk7rD567Zi9lB0hSwOAGG5drAQCSwHB4hZcjaq7BytQsrXYeuZcuS9jYoOYKrstPv2kAPq29pAvaHEq4OAKRAkUL1sybxICwv5rCvgxLZsqSiBUq5iOq0TiEv8A+S1wA+Ul2tRpe+hJrGtEFielDPC0RLirveTPcTmnogBeTA1FBmDmW2T3OfBNralEElOUZtEq19QNvf5rU2l8tITmOEgUyclMwFQo87ge4nJDRsG1Ku5uZ2zs0hpWtbWChz2RqvAB8jIpFGLBYbRAqFQq5IQwEVMCwWrxdhkueLkiRknSmRrLv16lvDr7711nCvqo9UiKiRpxwJG8Q0rac79B3pPTLA4UwgRI6wg+CdtIGjjkjTDFJcOizR55vDSTIjbNUpvtEIzRFIIAh9klQexVHZlSKIR/sL/5MguzhAyqaQT0UECwjbnNVeHDQQJT+QWQ6QJmwg2xfhH0RO5y8LHogoKCA9A3hYCTOIcw0jSLH9vwayfGvNBYxJqztnDdPlW3L+ayBEHy0xM12138R4+ZnJoIFAmqCAdDUuH4qZrtrqCGG8fDYPEGV5fkfHxo6OyEhdqfKfBmnPXT4MP4khxt4AQbRDOW635awcoIo4S8Z0dPTB4B/QmdHujIzJuLicnIbcxhh1cV5LnzYYIMNt/ouGtZX5VIbOD0jkUEN27rFa5qJVChQZWdOxMa+9a6opvjLn/NqDay1t9rKxtJWAdHT6B+mPZT512r3g/oJ7nJ3tzoiryKm0xzfFXBAT02iv7Jw+2GIfU/KvI3KQf5EtxVO505YNgYPUnOYfRO9kPtU2vFyIdJHj4O4OqdVnq5GV+Y2lAiu7nDTws2wMFCTSQqp1ib5XtamYL28fClqrJSc367VlvEHY29GLunsd9Kue42C+fMxAcEDoLhE5nVrGYTzQLr85EkcadV9dF0K/RnM38+XVjcEFIdQUp6WAHEjJkQP5VHbtVs9an+aXpbLnNQQZZK2txBSaig42LGIyyEEUkIPYQPIqSX9s9YTSi28zS2U/yhLsHHE5PJ4itCBPTStJIIduJ3FsP5R7iAJ0en8iHUSVyDJCig4ySKo11eGUYU3LBW0kEOhwEsi1EBtIzrGkP7a4Qmgg7Jd3a3mkcVSEyeAz1VWIllVFHGqwkyNtTcTig4qyQKdiWXyIoAw1jrHaeIN0bgw4R7hdlJPty8tBptgDDseXTK89INbEshwUQ2l87qxbwxskvusfBIFm3MsLdGEqGfQ4vogtU4UxL9DNRFOWrbdYId4gAz3/JAjUsrxCV9KqitXrjUajXh+rai1hzpAKym3VnlhEK4Aa9ouN5QYNhGMRuyTMpCrMzMwsVJnCSpgXsXOpiYnYGgvxBhmv4Jmii6+8LD3rm2+y0i+78mIhIATJ2sTUMESpiWuZOcqItFCN0GugEvDfP0ikhVeKLj4i67hP9yz98svSnk+PyzriYiEgBEooJoDByDEZQVvzXYcfOBznWK1GBES5QpBb0n9eendZSz+n38INItT01jM5mEZ1qU7BQ/W2FlmtThBQtEKQ57M+fZeiT3c/LwyEWz2naaHkBPM60A2JxRoRkGQErO+GI5JKU6Q6CPILUs4D5JbPvnqXpq8+uyW4IDFxWnSeXu9NKljXNaQgFPUSoCiEDBYfKQcfoBNW5DPMyDivf3E6lh9LnyCV/ZMlLE/SLw5m0RqwaPm0jEnsOZLXwBn+iJ+RlC98nHXqCYcddsKpWR8vIH//fD03CG+r7lBGBMQLRMt6qquRM3zWHoTj5+PPOCL9s8/Sjzjj+J8Rkj1Z3CB8rbobanXQSkF6BrjCX3kckgEfH39P+sd7FhaWPk6/5/iPEZfjruQE4WnVbXGXQysGacjjCn8ZUkOWss4ANQWvHWdkIfXkk8v4gxBW3e99rbo17hpo5SDTaVzh0/cgqT71CDQb0Mw54tRPkIYrnSFYpkkPJdoIF5pVd/u+D+b20qy6R7k3QoGBOE3G0G4H74kVevuzTgA8uPakn5CFZhJDMJNJbyqy4i50q+73R//xAdC+78lW3ZnaYYg3SAEFRJaoL9Tr8eLZyRn+s19Aqr857LMFL8jCZ4d9A75++YwhmKvb2ZqpCiG5iMWgAxg9ceshZBDCqqubVis0CSKDHIY1BtAJSszh5VIpPSFgrhaFgngbN7E0Jdx817mnb9lyJwwniEQJnTeIFKAfhcVITwOL5Ehvs14sJ+aDIhEG8iYZ5G0MREQWDOaMhuXpIeoEXMTk5aBd+7bt/2D/tn27iOWg0owhynhXmoJ0gKDHk8MKzXqUTy6WK3TYEwgKcYImXwMMvfKqKEl9eJqUeEIYtHvcdQQrWku0orWHXke4V+N/2Ldv2759PxDrWlqLWiPwcSeY8dQwGDhzt1rMlf3Ty4SC7Dp6/7ajt+0/ejlHlHFlEBwUkJwx7vAn7F5gan4Xdp8gEARo7/65/XuJJdOcRig4IDO1fMKnf0XqEPcgHSLe+goEIfoRHKQyFwoYpIiy5GUf4BP++uOYhijHXU9flxVqscptgAIHsTaTulRttJYrPJElxKDxkwU0QzJNJSsCacyBVgDidJDHag38wl+5e4E+jF/Y/awstjBxBSBTFuVKQIyx5AGjmmf420GloOqT22VAqjUBg1wIGv6VgDhU5MW5IZ7hX9y9ROVY2v0CAmIrCRjkjpqQFYH0knOksYtnePj64xbIHAvHPXQMCpIaMMjm0JWB9GeS3KbK+IJAoMWlFiwMJFEYyN73gT5HQVQrBGkmt1rFdt4gZ2R9RVp5yHoCA8kMFQSy9+sfgfajIHeWcIPI/YDUOcgzmgreIKDl+sXL8QtosVCQ2O4QQSDv75hDtG0vAGkdWWGO2FisPJwpOvW4heUKIkNBYlWhkDCQDxDNffg+0vyGw1wgSVI/IC4T2XFyhj8IBGbqqD6+XYaC2FRr+QSzOZlBQs3J3E/0s4Nc4aI+QCcA5DBs1PgpqCBAp2e2hrIEC6G4dBtbcasuAYJZdZPrAwc50Ym0WsYSqxOJHhqyCwCBbkEq/FdIDxJry9yyNoTBU4lDZfUUmcjB7rywD0y2zt16/+zs5/vngE5GQO6/8FyN6MiEKIkkH18WLZciUvIE2Wo1ISCQ1Wh0gIPySSEg0Bu7l5Z2v2azZZpSQxWMnkKtrUUuvZ5woVl1t10DNLcXt+pKR9Kk0j4U4yQJImyqiS79GsSjI97GC5YrREqmYXxsmDPVhHYB7ghikulVykg4rnoJrnyJCNX1u7NefvTCrX2jGtT0CRP2UHxi23fhieeeC8MikRw1mILJNNmqi3ckewmrbp+fotVnVsCwXD6YoEWSpzGIYXCRBPF6BRI1uDx2EXxavukGMTjvVRKebPB/XbhXaYBtECUEET50RJFLkMWKw6qrg1lBBvF7rOA1Q2yv5Fe0iAO9XhgIh1V3XTLMxsESLQzdfd15E/PzE+dddzfhGuEWChJbJABE5TJRrbr3X0O36kaNMGEo+yLYuvfHLplf/PKLjz764svF+UtuW3a21DCAZDoSWdKogGKtAkDCPDKqVffV032suusZ1nGkfcgnY9G6af7Pj97D9dGf8zct21dimHJExpbGtGRXoQCQVKuJatW9I9bHqgv3KenhzOSodElUji/fI+nL+Zu9wy0LA0hRfzfNxVaXiB+5WOvIiZ4iekSy3lCKVbf7DsKqS/CPQhBl0LYumWqSTiOOD5v47j2Kvps4jH2FDs50ptJc6hLx9Mv1NlYQwEiPyKOiWBY64girLgGi02TqyQOOBPqYS7GcxqcWsTL10+LiT1gJW3zazyMcmXo6iOOcMO9NZi1aW12FPjlCe3Lh2LYQIJ8SaX7LZCwh0u17a5PxDvGw+W/RbJi/5MHLL8EK2bfzh7GuYsNWD92l0OYtWuyVXdGb6BOsrpDjMQssooLNDkei35GJVq4ABe7hX/GKgXi6ef4L5K9fH8a9ZFPqGl4PSmguTsLkwTqN2BIW69P80p7uabezNPajOquMyxasU8D3/YQm/XXUE4710334+bYNPlE7jXSXbtLwibWO9Ou52rFiO2PRAuojpgRyJcQi5SlfoIXJGwotaF+c4jWo5vqAOIrodcTYywNEFcZl1X3uJcKqS/WkJWp4hIiFo+6KCSTlX57nDXXelwjXhDdctA+Ivtknj6wcIIKtunRPKUQPPsoC0q+f/wjJgglvqAkkgz6aZ+3c4VabT4q6OUCEW3XpnszVXpeUZHo8wLRjhqBmIwABAnUdhEJq+3sUkLIYviniBkkNwWeIoSxW3d/2/ka16hKeROUsHYkoAnzUi+uddSALkOp9ym1IqNtOAVUfzSCkaQLFuiYuaCA2k0pvk3mcmUY9s1X3h50f7PwBKV1XhdWjMwVs0pCETWj6xBp0kjNCtWJFpaBf9cfceB7Wr/81cfkjd18+8RfWt59nbbY6e/VoAxwsEJPR1e+UuZx1HhnFGCpS9F34JJITO3f+uHMncvDk5mRg9kTUJ0WFtkreHEleR13fisIqu+3yRXyItTgxsYgPuhavK7Q5VVakw8oZZkm2FFead85YDe4fKvThWHyOCOOG0/Ua3BJq8J7Jp7+ru+uD/bP7P9hFvKvL9pihvJ7iIBmBDfVQs+o2UEmoAlXkMe80MeHsC8TYxBGfth6JnyEmjSD5ZgxlJFyHwYGJTwAWqx1//P7HDsL0xr6fRJRBSnIwgP/VigIpdAp5zIiXLNpj5cKLViAgJ4PloJMJEH/7EiXBMGKihhNgHR5RRLLiwUU6yOLlpC42W7laILO/f/D7LBVEpOSKKdlQ5T18B4yvKPp2fnNm4bLprGJ81UC279hOA0nScsdkgA24rwcWaRnygExGmM7iu1YNZPa3WeEgwEGpKECPbpv/lpohT8hIprMh++qBzNJBCiL4gIBZ9ijanTy9SM0QDwpiM2Hz3bh/ESSZHwiQ6CRkbkWuJV/MP2q8otfj6e0vOseVidT2fxEkSsoTBHitBh8P/kqA/Hqdp9/TCzh6m5sLjehLJEEAEUtxlYfjGuEFokM+C7GpZEkoY9zi8mp0FFOVAHo5oi/57pRjTgc65phHbzwX7RHF5+drsAOxGPEuAt8JiIMGP6iSox023lkqKAcKrHPH+vooCS50INXHB0SShnwabQ5HbGxRs7G3uZsKcpEClleZU8ANisBG+DfNf+Tt1B+RAZFNZzktq1206CBOh0omc7qsRlehihRunUJRTo7IXA8+7lvEa/olOAdhOqvMWxWQz99/HzCgdt1tW08cTECzPH9UZEDyFgZ5v0WODo7Wo38nAGtCPT0iBbq49SU6cpy4GsUgm87s7asC8v7cjg8//HDH3PvkHKlO4YiJBoIVLqJgxZJNZ/bi1QHZsROYR3buoIKUYwt2obxANEpsnQ4vWMB01k2YzlYtR2Y/RIvWh5Q6Yg7v9diMMjAXW8sD5CQ0/y4+5aefTtFjpjPKokzu2L8Jonf0X9Fc1HzFrfgWM0lV+KsXGqQRlYPmsgp7RQG0joPY/kh3z88/1p3q+4JT7oZ/DgRNk+bcLTQQYAxNQCq2OL8PmfZcDQCqw4GQdnZQyRI18bbSdZfjg3dtaWl5JK6jOnLU3uNysG/AaKl2FevIunohlV2ig8qPVTdWnmXJqMV2bcgAssThOjgD+7ZkIDq/Nhro3myw8wC6vcN0xvkZk8jpzpycs3LaKlFdEI+oKQZRmRrR2VMxmHricW0C/lhbLeEgypr2nk0ZB082NHVtyI8sLYV81eCvH4kAeZSG5NZ4R0dH/nAeorHiG4qLi4fUqMqw9IMdFqbUuIo77cXFwE9eHks/QoCEm7lAlJH5XU0Nlmx3XO5AVRoLKw5yrPA6YjSG+rm8upGjZ+cGUUa2dMXYOzOyoy2b4tXHRuKn/IMMCwdx9fsDGa/gBmHc3Rfsf9F+dlNuBZL+tvip9o40SkTy4IPo/eaIMpsbhGiHdEe1tA805XZa3GBHkpzcC9RjHWmMUZvDoaAXLavDX8nW1qqn4jflnBbH+ijghg1D6J6SGWdmZ0/H5dib1GMbZ5QcrRYMBR+kyMbmSadum47utJd1VXXUREIdwxuG1DGNDTSQZzor43vUxcPj48k8Fh+kQKwga8E/RInMrdZFIlGB3xwxMl6+tMxSG59XSrh0tuXG95RNbfEtWvx3bh6UAMEwC4gRslmdsbJCTx3TMF6XjJpc2EEcTDmis0c31VBcOJtfKQcI4RAxOjgouQiffKZJUSFdt96pL/TIHDJPYSgDiBzNUn850lxCXSFWQNBA9JTSX8/+9Q5k2fRrvENk3163tb+QkUyMPeuzToKtQReIRMsPCcHLDwmJlx3XI47ofVLKUU8GbMU6ATknxxev199444kiQiBacelpbaWcQ5RZYojCvgeq6ZxMqgPbDaWrMoBB4zl19Oerpgc4R78/7Ph9xw8fztJAQk1026sLEgoS+Oi3Tk+zHp/ZzmMYf/TRR9OG8dokVb+KbtYuYozopHJOkLEAml8n1U/c2RAPkO3gHxVEKVL10kFMzTQH4iv4IE5qeYjPDXAYr9SEWVPpII4SoSCBT3VlVsozDZNKXosP4JFy2jAekqtc9MGOnla0DCkRqE9OkPgh+vVbWzlBusljRPeMkOUgKojNk0h/qsfVSnFIkBQAo09yOSdIYxf9+lZPrKeoyGrkN0OMdHcEvEAHCpKnkBbOaGV6yPvIem6QMvKms4jpc/Pmq+/avNnswftRqVcJ3oNBKSGzu53mQvjhAZIaupYGUncO45OlEhjM8Q3gfwJYBRYjFlgR+l6rYvmh3bPPJpZt0V4TV1QU4UKI7mTIzsddokQUIf0oDxCGDpHWR8lxj5w5MtCzgp1qatx5HMFWDKJBp5KbHU4ukK7GwEE2ujdCwQYhDkkGU7myOYwLZCw3YJBh9zj0j4Mk6a3doL/vN3H91kpLW6AgxbVHQf88SIGr0ATaMn0IF0jNaQGC9GTMQEJAvj96bv/7AYBYi4CryqHi+omstIyAQJQNOVpICMiur/fOfr7vB8EgyUYXcHVCMnKtYZQ7EBCdJV7Ykun2ua8RS/vXgkGinEXog5Uq8i4WjMoOAGTcPSR07Xcf+vzGnHCQOhkylOkN4QSZnBEMogbNrhCQA0GOfL2cIwcKApHUNSMgdaRaE8EM0jYsEES7qUIHCQI5iFJHXhEGclcscHVdEcoJ0qgWBlIzfQEPWvo7VkSrtf05QSDVd5mQMZmeVP1ZQLriBYHEnJk/qDHIsRULBf6cWpUhQccKQts58+gNwkD6eyFI0W0NIUCkzCBHWQSARGw6S8f8IpCY7ztWL96YGsrvcsSSr66Az08NRkfwBuk4s5HtBcGoFJ7vWJ3+TOIaoSBQAh+QeDU1HlUIW8zqM4fZX+RU+Lgw75xpu4MwKldpvNviwPhTneuJHW+wc/n5yDn4yGqi+qexgGykbQ7uKWpWOepchXqjipqiyvPZokByQ6Sjg7DsnFkbybskE+Y5UdSomQsEyqbGY5R56oqcsXpHEQVEe9YmyK8MUfx2zmxoDwAEGoXMCliMKH+U2N4IFhFSiM+qQh3kiCf8sV3cpwGZ6mH+xefbIQ6lyWExKhibIbLsnDnQxB+EMM/VV3P/HOdUD/ePr5XXDnFg8N45s6NCCEh5Nf1Fhvp1EItq4jhTdFTtBr4I3NsbZgsBSfGC6JKJPGJTtpIjRTXuYUgoCLs6W4SAXER/vTLlJI6Y2VMU6W6BgqipJgEgaRJi1Mtq5yJ+isUvyEztscHkAPEJACGSbS5ntaoQPw7jD0Q3PQYFV51jgYCEm1l/f4awjPsDsXRBQdZ4Bn8QabLPFsBy9pgzZthBcuOhoKsijzdIRDLHEwP0yRWbzj4NCr5aKniDEDWiupwbpEnN2secWQoFqr8BWENZomY2XmsAAAAASUVORK5CYII=", "public": true }, { @@ -49,54 +37,39 @@ "type": "IMAGE", "subType": "IMAGE", "fileName": "image_map_system_widget_map_image.svg", - "publicResourceKey": "QKRIYhDeBGwjaeIS601VvNLSsvZ25DRj", + "publicResourceKey": "P4hZLRjmo2P1RjGPZ4CrCMxh6xOQFQr5", "mediaType": "image/svg+xml", "data": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTEzNC41IiBoZWlnaHQ9Ijc2Mi43OCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTI3LjA3MSAtMzA3LjkpIj4KICA8ZyBmaWxsPSJub25lIj4KICAgPHBhdGggZD0ibTkwNi4wMyA3MDYuMTMgMy40MjkyIDE3Ljc5Nm0tODgwLjg5IDQxLjEyMWMxNTAuNDQgNi44MzM0IDE0Ni4zOS0yNi4zMzQgMTY2LjQzLTI5LjMyIDM2LjE0NC01LjM4NDggMTE0LjI5LTYuNTI1NCAxNDguMzMtOC42MjM1IDQzLjM3OC0yLjY3MzggMTQxLjc2LTExLjIzMSAxODguODYtMTkuODM0IDM5LjgxMS03LjI3MjggMjIxLjM3LTAuODYyMzUgMzE5LjA3LTAuODYyMzUgNzAuODI3IDAgMTQ2LjkyLTEuNzI0NyAyMTguMTgtMS43MjQ3LTMxLjYyIDAgMTE3Ljg2LTIuNTg3MSA4Ni4yMzYtMi41ODcxbS0yNS4wOTEtNjguMTI2Yy01Mi44IDM0Ljc4NS02NS44OTUgNTEuNzQ5LTk1LjYzOSA4MS40OTMtMjQuOTMxIDI0LjkzMS0xNDAuNC0xOS4xMzktMTc4Ljk0IDM2LjY1LTEyLjI4MSAxNy43NzctNDcuMDAzIDQ2LjU0Ny02NS4xMDggNTkuMDcxLTIwLjEwNSAxMy45MDgtNTYuMDM3IDQ0Ljk1Ny02Ny43NjkgNzMuMDc4LTQuODAxNSAxMS41MDktMTMuMzggMzUuOTkzLTIzLjQ0OSA0Ni4wNjItMTAuNDk3IDEwLjQ5Ny0zOC4zNzcgNi4zODU3LTQ0LjAyMyAxNy42NDgtMTkuMDA1IDM3LjkwOC0yNS40NjUgMTAwLjkyLTY3LjYxOCAxMDIuMDVtMTkuMjgyLTYyNC4wMWMzNC42NTktMS44NzM4IDg0LjAyNyA3LjM5MTMgMTA5LjktNC4yODU0IDEzLjI4Mi01Ljk5NDEgNDEuNDA3LTIuNDYxNCA2Ni44MjktMi4zMjA1IDM1LjMyMiAwLjE5NTc4IDY0LjM4MiAwLjYzNDc3IDEwMS45MiA1LjAyMzIgMjUuMDMgMi45MjY1IDQ0LjY2MyAzNC4yODcgNTguNTI3IDUwLjY0NCAxNy4wOTkgMjAuMTczIDYyLjc2NC0xLjcxNDcgNjYuMzA2IDMyLjEzNCA1LjEwMjcgNDguNzY2LTYuMzI4NCA3OC42MzcgNi4xNDExIDk3LjM0MiAxOS45NjkgMjkuOTU0IDUwLjQ4NiAxNy44NTYgNDQuNjE5IDgzLjk3MW0tNDcyLjQ1LTM3OC43OWM0LjY0MzUgMjMuNzI5IDE1LjA2OSA3Mi43NzYgMTkuMDYxIDEzMC42NCAwLjg3MjA2IDEyLjY0IDUuNDQ3MiAyNC45OTMgNC4yMjIzIDQ1LjI3OC0yLjUxNzIgNDEuNjg4LTE1LjcxNyA0My42NzctMTUuMDkxIDYwLjM2NSAxLjQzMiAzOC4xODIgMzAuNjE0IDkzLjgzNyAzMC42MTQgMTM5LjcgMCAyNC4xODEtMi42Njk2IDExNS4zOSA3LjMzIDEzNS4zOSAwLjE1OTExIDAuMzE4MjEgMTAuMDY1IDM1Ljg4MyAxMC43NzkgNDkuMTU0IDAuOTQzNzggMTcuNTI1LTI0LjQ3OCAzOS40Ny0yOC4wMjcgNDYuNTY3LTUuNDc3NyAxMC45NTUtMzYuOTczIDEwLjg4Mi00MC4xIDI0LjE0Ni0zLjg2ODggMTYuNDE1LTMuODY2MyA0My43OTcgNC4wNDY1IDU5LjQ0MW05Ny4zMzctNjkxLjAxYy01LjAxMzMgMzUuNTE2LTQzLjY1OSAxMS4zMTctNTguNTM5IDIzLjc4MS0yMS4zMyAxNy44NjktNjIuNSAzMS40MzItNzAuMTI0IDM1LjM2Ny0zNS4wODggMTguMTA4LTExMC40Ny0xNS4xNDItMTI1LjYxIDQuMjY4NC0xNS45NTEgMjAuNDQ3LTAuMDczNSA2MS40NjYtOS4xNDY3IDg0LjE0OS02LjAzNTcgMTUuMDg5LTE4Ljg3NyAyMy4wMTctMjcuNDQgMzIuOTI4LTE5Ljc0OCAyMi44NTYtNjkuOTc0IDY5LjgyNC04NC43NTkgMTAwLTcuNDk3NCAxNS4zMDQtMy4yODQzIDQ0LjQyLTMuNDcwNSA2My4zNDMtMC4xMjc5MyAxMi45OTQtMC44MTAxNSAyMy4xMDQgMi40MDM0IDI4LjI3NiA0Ljk2MTYgNy45ODU4IDIzLjcyIDI4LjExMiAyNC4yMzkgNTAuNjExIDAuMjk0MTEgMTIuNzcxIDAuMDEzMyA3OC41OTEgMy4wNDg5IDg3LjY1NSAyLjMxMjYgNi45MDU1IDQuMjIgMjYuNTY1IDEwLjIxNCAzNi41ODcgMTEuMzU0IDE4Ljk4NCA0LjM4NzQgNDAuMTU3IDI3Ljg5NyA1My41MDggMTkuMDUgMTAuODE5IDQ2Ljg3OCAxMi4yMTkgODEuOTI2IDE0LjQ2MSAzMy43MDMgMi4xNTU5IDYxLjUxMi0xLjQzMDQgNzYuOTIxIDYuMTQxMSAxMS41ODUgNS42OTI3IDguNTgxNSAxNy45MzMgMTQuMjk1IDI5LjM2MSA1LjY0MDQgMTEuMjgxIDMxLjUwMyAxMS4xNTYgNDEuODA0IDQzLjQ1NSA3LjYwNTkgMjMuODQ3IDMuMDg1OSA0NC4xNTcgNi43MDc2IDY1Ljg4NyIgc3Ryb2tlPSIjMzY0ZTU5IiBzdHJva2Utd2lkdGg9IjMiLz4KICAgPHBhdGggZD0ibTQzLjI3OCA1MTcuOTVzMjMwLjg1LTMuNjM4IDI1MC4wMS0zLjY1ODdjNy40ODIyLThlLTMgOC42MTk1IDUuMTUxOSAxNC4wMjEgMTEuNDU5IDI0LjU5NiAyOC43MTkgOTMuOTEgMTEyLjk0IDkzLjkxIDExMi45NCIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogICA8cGF0aCBkPSJtMzUuOTYxIDU3Ny43czE2NS41Mi0xLjY4NDUgMjQ4Ljc4LTEuNjg0NWM0Ljk0NzUgMCA3LjcyOTktMi44ODMzIDEwLjUzOC01LjcyOTggOS42NjExLTkuNzk0MiAyNS42MzItMjguNTkgMjUuNjMyLTI4LjU5IiBzdHJva2U9IiMzMzYiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgPC9nPgogIDxwYXRoIGQ9Im0zOC40IDY0MS43MyAzOTMuMzEtNC4yNjg0IiBjb2xvcj0iIzAwMDAwMCIgZmlsbD0iIzMzNiIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogIDxwYXRoIGQ9Im0zOS4wMDkgNzA0LjU0IDQ4NC4xNi02LjcwNzYiIGNvbG9yPSIjMDAwMDAwIiBmaWxsPSIjMzM2IiBzdHJva2U9IiMzMzYiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzM2Ij4KICAgPGcgc3Ryb2tlLXdpZHRoPSIxcHgiPgogICAgPHBhdGggZD0ibTMwMy45NiA2ODIuNTkgMTQ2LjggMS44MjkzYzEwLjUzNCAwLjEzMTI3IDE0LjM0NC0yLjYzNzQgMjUuNDg3LTYuMzcyOCAxMC40MTItMy40OTAzIDMxLjQyNC0yLjY5OSA0MS4zODUtMi43NzM4bDQwNS41Ni0zLjA0ODkiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDI2LjIyIDMxNC44OWMyLjA2NzUgOS4wNTI3IDEuODQxOCA1MS43MjggNi41MDc5IDc0LjgzNSAxLjY3NDggOC4yOTM0IDguNjc1MSAxNC4wNjYgMTAuMDU1IDE0Ljg1OSA0LjkwMTUgMi44MTQ2IDEwLjgxNSA4LjE0OTggMTMuMDQ2IDE2LjA4OCA2Ljc1NzggMjQuMDQ2IDAuODc5NzIgNjguNDUyIDAuODc5NzIgMTEwLjY5IDAgNi4wOTc4IDEuNjYwMSAzMC4xNDctMi4xNTU5IDMzLjk2My0yLjU0MDggMi41NDA4LTAuMjgxNjMgMTIuOTkxLTMuNDM2OCAxNi4xNDRsLTkuODQ5NCA5Ljg0MzFjLTEwLjM2NyAxMC4zNi0xMS41OSA2LjUyNjEtMTcuNzM4IDE4LjgyMy0zLjU2NzcgNy4xMzU0IDUuNDAyNCAyMC42NzIgNy4zNTQzIDI0LjU3NiAxLjkzMjEgMy44NjQzLTEuODQyMiA0Ljc3NzctMS43OTI0IDcuNDQ2MyAwLjI1Mjg2IDEzLjU0NSAyLjI5NzUgMzczLjkzIDIuMjk3NSAzNzMuOTMiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtMzY1LjI0IDUxOS43OCA0LjExNiA1MDIuMTUiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtMTE2LjUzIDUwNC4xOSAzLjg4MDYgMzEwLjk2IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTMxNy42OCA1NzYuNDkgMTMwLjE5IDEuNTI0NGM0LjUxMDggMy4yNDE3IDIwLjM0NSA3Ljk2ODUgMjcuNzQ1IDQuMjY4NCAzLjE1NTUtMS41Nzc3IDkuNDE5LTUuMzg4MiAxNC4wMjUtMy45NjM2IDQuMjY3IDEuMzE5OCA2LjAxNjkgMy4xMTYzIDEwLjM2NiAzLjA0ODkgMTAuMzA0LTAuMTU5NzUgMjAuMjEyIDAuMzg3NDEgMzAuNDg5IDAuMzA0ODkgMTc3Ljg5LTEuNDI4MyAzNTYuNTktMi4xMzI1IDUzNC43Ny0zLjA0ODkiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDc1LjMxIDU4Mi44OWMtMy40NDQyIDExLjM1MS0yLjEwMzQgMTIuNDM0IDMuNjU4NiAyMS4wMzcgMy43OTQ0IDUuNjY1NiA1MC44NjMgMTMuMDM4IDQxLjQ2NSAyNy4xMzUtMTAuNTM3IDE1LjgwNS0yMi44OTctNS40Nzc3LTMzLjg0My0xLjgyOTMtNS40NTI0IDEuODE3NC03LjM0OSA1LjQ1NjMtMy42NTg3IDkuMTQ2NiAyLjgwNjggMi44MDY4IDQuMDQ4IDEuODA0IDYuNTIwMyA1LjEwMDQiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDMyLjAxIDYzNi44NWM4LjMxOSAxMy4xMSAxOC44NDYgMTQuNjM1IDM1LjY3MiAxNC42MzUgMi45Mzg2IDAgNy44Ny0wLjkzMzcxIDEwLjY3MSAwIDExLjM1OSAzLjc4NjQgMjcuMTk0IDEwLjI3NiAzNi4yMDIgMjEuMTI5IDguMjggOS45NzY2IDEwLjI1MyAyMy44ODMgNy43MDIgMzcuMTA0LTYuMTY5OSAzMS45OC0xNi43MTQgNTYuOTg5LTE5LjA0NCA4Ni41NjktMS4zNDggMTcuMTE5IDQuNTA5NiAyMi41MzUgMTEuMDcxIDMzLjkyOSAxMC42NyAxOC41MjcgOC43MjQ1IDE0LjIgOC41NzE0IDM0LjI4Ni0wLjEzOTYzIDE4LjMxOSAwIDYwLjI2NCAwIDgwLjcxNCIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MjguNTEgNjU4Ljk2Yy0xMC42ODEgMC45MDQ1NC03LjEwOC01LjYwMjYtMTAuODI0LTguMDc5Ni00Ljc4NDUtMy4xODk3LTEyLjIyNy0xLjI1MS0xNi43NjktNS43OTI5LTAuNjY2MTItMC42NjYxMi04LjgwOTctNC4xMDg4LTEwLjE3NC0yLjc0NC04LjM2NDYgOC4zNjQ2LTMuMDQ4OSAyMC41NTItMy4wNDg5IDMzLjUzOGwzLjAyMiAzMzkuNyIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MTcuOTkgNjUxLjAzYy0wLjIyMTcxLTIuNzAxOCAxLjkwMzUtNS41NjIxIDMuMzUzOC03LjAxMjQgMS43OTk0LTEuNzk5NCA2LjkyMjkgMS4wMDQyIDguODQxOC0wLjkxNDY2IDAuMjg3NjUtMC4yODc2NiAwLjg0MzI5LTExLjE2NCAwLjIyODY2LTEzLjU2OC0yLjA2NDgtOC4wNzQyLTIuMDU4LTI4LjY1Ny0yLjA1OC0zOC43MjF2LTczLjE3MyIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MjguNjYgNjc1LjQyLTAuNDU3MzMtMzEuNTU2IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTc2Ni4zMiA1NzkuNjQgMC40MzExOCAxMy43OThjMy4xMzY0IDQuNjY5MiAzLjAxODIgOS42MDA3IDMuMDE4MiAxNi4zODV2MTU3LjM4IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTExMjIuOSA3NjUuOTFjLTIwMi4zMSA0LjY5MDUtNDAzLjc0LTEuMTEzOC02MDUuOTUgMy4zNTM5LTEwLjg2NCAwLjI0MDAyLTMuMzYxNS04LjU4NjMtMjguNTM3LTguNTg2MyIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im04NjAuMDEgNzM3LjA3cy05Ny40NDggMC44NTgwNi0xNDcuNTcgMC44NTgwNmMtNS4yNjg2IDAtNC41MTU1LTguMzI5OS03LjMwMDktOC4zMjk5LTMuOTc0NCAwLTguNjI5MiAwLjAyMDEtMTAuNTA5IDAuMDM1OS0yLjMzNDggMC4wMTk3LTEuODEwOSA4LjM2Ni00LjE0NTggOC4zNjY5LTQ2LjE2OSAwLjAxODgtMTY3LjQxLTEuMzA4LTE3NS4wNS0xLjMwOC00LjQyOTYgMC04LjU3NjMtNi40Mzk3LTEzLjEzMi02LjQzOTdoLTE0LjM5NSIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im02NzUuMDEgODMxLjE3LTAuNjA5NzgtNTIxLjc3IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTc5OS40IDMxMy4wNiAxLjIxOTYgNDk1Ljg3IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTczNi41OSAzMTIuNDUtMS4yMTk2IDcxNi40OSIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MzAuMDMgNjQzLjQ2IDM5Mi4zNy0zLjAxODIiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtODU5LjQ1IDMxNC45IDEuMjkzNSA1MDcuOTgiIGNvbG9yPSIjMDAwMDAwIi8+CiAgIDwvZz4KICAgPHBhdGggZD0ibTkyMS41NCAzMTAuNTkgMS43MjQ3IDUzMS43NSIgY29sb3I9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgIDxnIHN0cm9rZS13aWR0aD0iMXB4Ij4KICAgIDxwYXRoIGQ9Im03MzYuMjkgNDUzLjMxIDE4NS42OC0wLjMwNDg5IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTEwNjAuOCA1MTQuOTdzLTM2My4yOC01LjYyNjItNTQ0LjY1IDIuNTIxOGMtNC4xNzc4IDAuMTg3NjktMTIuNSAxLjA2NzEtMTIuNSAxLjA2NzEtMS41NzEgMC4xMzQxLTIuMDAwOS0yLjMyNS0yLjU5MTYtMy41MDYyLTAuMDk2Ny0wLjE5MzQzLTcuMDYwOC0xLjkzMzQtNy42MjIyLTEuMzcyLTIuODkzMSAyLjg5MzEtNy42MzE3IDQuMjQ4Ny0xMi4xOTYgNC4xMTZsLTExMi4wNS0zLjI1NzgiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtMzk5LjgyIDQ3OS42MSAxMS42NDIgNS42MDUzYzIuOTg0MSAxLjQzNjggNi41Mjg4LTAuNDc3MTIgOS45MTcxLTAuNDMxMThsMTI3LjIgMS43MjQ3IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTUxOS4yNSA1MTcuMTItMC40MzExOS0yMDguNjkiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDMyLjkzIDM4OS43MWMxMS4wNDUgMCAzNS41MzMgMC42MTkyNyA0Mi41OC0xLjAwNCA4LjQwNTItMS45MzYyIDcuMDY2LTYuOTUzOCAxNC4xOTctNi45NTM4IDcuODA5NSAwIDYuNTQyOSA4LjA2MjQgMjAuMTQyIDguMDYyNCAxMy45OTEgMCA0NC45NzcgMC4zNzg4NiA2My45NCAwLjM3ODg2IDEyLjA4NCAwIDgyLjAwMyAwLjMwNDg5IDkzLjYwMSAwLjMwNDg5IDguNzYwNSAwIDEzLjE2LTIuMjg4MyAyMS4zNDItNy4wMTI0IDcuMTk1Mi00LjE1NDEgMi4wNTQ2LTkuNDkxNCAyMC40MjgtOC44NDE4IDIzLjE0NSAwLjgxODMzIDEyLjY0MyAxNC4wMjUgMzIuMzE4IDE0LjAyNWgxNTAuOTJjMTQuMzMyIDAtNC4xMTkxLTEzLjExIDI5LjI2OS0xMy40MTUiIGNvbG9yPSIjMDAwMDAwIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgZmlsbD0iIzAwMDAwMCIgZm9udC1mYW1pbHk9IlZlcmRhbmEiIGxldHRlci1zcGFjaW5nPSIwcHgiIHdvcmQtc3BhY2luZz0iMHB4Ij4KICAgPHRleHQgeD0iNTg4LjY3OTU3IiB5PSI3MzUuODA0NjMiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU4OC42Nzk1NyIgeT0iNzM1LjgwNDYzIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+TGluY29sbjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI2ODYuMzk4NSIgeT0iNzY1LjYyODQyIiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI2ODYuMzk4NSIgeT0iNzY1LjYyODQyIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+SGFycnk8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI3MDkuODcxODMiIHk9Ii04MDIuMzc3MzgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjcwOS44NzE4MyIgeT0iLTgwMi4zNzczOCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldvb2RsYXduPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNTYyLjExOTI2IiB5PSItNzcxLjk2ODE0IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1NjIuMTE5MjYiIHk9Ii03NzEuOTY4MTQiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5FZGdlbW9vcjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjU5OC4zMDQ4NyIgeT0iLTczOC4zNjY0NiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTk4LjMwNDg3IiB5PSItNzM4LjM2NjQ2IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+T2xpdmVyPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNTkyLjEyMjg2IiB5PSItNjc3LjIwMzk4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1OTIuMTIyODYiIHk9Ii02NzcuMjAzOTgiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5IaWxsc2lkZTwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjU5Ny4zMjcwOSIgeT0iLTg2Mi42MTQwNyIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTk3LjMyNzA5IiB5PSItODYyLjYxNDA3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+Um9jazwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjU4Ny4zNzAxOCIgeT0iLTkyNi4xMzY2IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1ODcuMzcwMTgiIHk9Ii05MjYuMTM2NiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldlYmI8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iODcxLjE2MTAxIiB5PSI2MzcuNTc1MiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iODcxLjE2MTAxIiB5PSI2MzcuNTc1MiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPkNlbnRyYWw8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iODczLjgzMjI4IiB5PSI1NzcuMDMyNDciIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9Ijg3My44MzIyOCIgeT0iNTc3LjAzMjQ3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+MTN0aDwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI4NzUuOTY2NDkiIHk9IjUxMC4yNjE4MSIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iODc1Ljk2NjQ5IiB5PSI1MTAuMjYxODEiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij4yMXN0PC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9Ijg4MS4zMTY1OSIgeT0iNDUwLjE5ODc2IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI4ODEuMzE2NTkiIHk9IjQ1MC4xOTg3NiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjI5dGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iNjE1Ljc5MjQ4IiB5PSIzODcuNzQ3MTYiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjYxNS43OTI0OCIgeT0iMzg3Ljc0NzE2IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+Mzd0aDwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI0ODQuNjkwMzciIHk9IjQ4MS42NTI4NiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNDg0LjY5MDM3IiB5PSI0ODEuNjUyODYiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij4yNXRoPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjU2My4wNDY3NSIgeT0iNTEzLjM2MTMzIiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1NjMuMDQ2NzUiIHk9IjUxMy4zNjEzMyIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjIxc3Q8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iNTY1Ljk3MTUiIHk9IjU3Ny44OTQ4NCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTY1Ljk3MTUiIHk9IjU3Ny44OTQ4NCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjEzdGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI0MzMuNTgwNzUiIHk9Ii00NjAuNzMzMTIiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjQzMy41ODA3NSIgeT0iLTQ2MC43MzMxMiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPkFtaWRvbjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjQwNS41MzA5OCIgeT0iLTUyMy41NDAxNiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNDA1LjUzMDk4IiB5PSItNTIzLjU0MDE2IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+QXJrYW5zYXM8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI3NDUuNDg0NjIiIHk9Ii0zNzIuNTg1OTQiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9Ijc0NS40ODQ2MiIgeT0iLTM3Mi41ODU5NCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldlc3Q8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI1OTYuNzI4MzMiIHk9Ii01MzEuMjU5MjgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU5Ni43MjgzMyIgeT0iLTUzMS4yNTkyOCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldhY288L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI1OTUuNDM0ODEiIHk9Ii0xMjIuNTAyOTUiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU5NS40MzQ4MSIgeT0iLTEyMi41MDI5NSIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPk1hemllPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDQ1KSIgeD0iNjk1Ljc3Mjk1IiB5PSIxNjIuMDY4NzciIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjY5NS43NzI5NSIgeT0iMTYyLjA2ODc3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+Wm9vPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjI0MC41ODk5NyIgeT0iNTc0LjQ0NTQzIiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSIyNDAuNTg5OTciIHk9IjU3NC40NDU0MyIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjEzdGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iMjA2LjAzMTc1IiB5PSI1MTEuNjM2NjMiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjIwNi4wMzE3NSIgeT0iNTExLjYzNjYzIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+MjFzdDwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjYyMC40NDMxMiIgeT0iLTUwNi42ODIxOSIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNjIwLjQ0MzEyIiB5PSItNTA2LjY4MjE5IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+TmltczwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSIzNzAuMjE2ODYiIHk9IjY5OC44NDAwOSIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iMzcwLjIxNjg2IiB5PSI2OTguODQwMDkiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5NYXBsZTwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSIzODQuMDg0MiIgeT0iNjgwLjg1MTM4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSIzODQuMDg0MiIgeT0iNjgwLjg1MTM4IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+RG91Z2xhczwvdHNwYW4+PC90ZXh0PgogIDwvZz4KICA8cGF0aCBkPSJtMzY3LjkxIDEwMTBoMjYzLjAyIiBjb2xvcj0iIzAwMDAwMCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogIDxnIGZpbGw9IiMwMDAwMDAiIGZvbnQtZmFtaWx5PSJWZXJkYW5hIiBsZXR0ZXItc3BhY2luZz0iMHB4IiB3b3JkLXNwYWNpbmc9IjBweCI+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNzM2LjI2NzQ2IiB5PSItNDMzLjEzNzc2IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI3MzYuMjY3NDYiIHk9Ii00MzMuMTM3NzYiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5NZXJpZGlhbjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI1NzIuODMyMTUiIHk9IjY0MC4yMDUyNiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTcyLjgzMjE1IiB5PSI2NDAuMjA1MjYiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5DZW50cmFsPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjU3NS4wODk2NiIgeT0iNjcwLjkwMzUiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU3NS4wODk2NiIgeT0iNjcwLjkwMzUiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5Eb3VnbGFzPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjQ5OS40ODk2MiIgeT0iMTAwOC42MDY5IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI0OTkuNDg5NjIiIHk9IjEwMDguNjA2OSIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjQ3dGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iMjE2LjY0NTQzIiB5PSI3MjUuOTgyOTciIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjIxNi42NDU0MyIgeT0iNzI1Ljk4Mjk3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+S2VsbG9nZzwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9Ijc3NC44NzU2MSIgeT0iLTUwOC4xODk3MyIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNzc0Ljg3NTYxIiB5PSItNTA4LjE4OTczIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+TWNDbGVhbjwvdHNwYW4+PC90ZXh0PgogIDwvZz4KICA8cGF0aCB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIDI4Ny4zNikiIGQ9Im0zNjQuMTYgNjU4LjQzIDI5OS41MS0xLjAxMDJjNi40OTg3LTAuMDIxOSA2Ljk3NzIgOS4yNTQxIDE2LjU5NiA5LjM5MjUgMTIuMDU0IDAuMTczMzkgMjkuMTExLTAuNTM1NzIgNTQuMTE0LTAuMzAxMSIgY29sb3I9IiMwMDAwMDAiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzMzNiIgc3Ryb2tlLXdpZHRoPSIxcHgiLz4KICA8dGV4dCB4PSIzNzMuOTkzMDQiIHk9Ijk0NC4zNTc1NCIgZmlsbD0iIzAwMDAwMCIgZm9udC1mYW1pbHk9IlZlcmRhbmEiIGxldHRlci1zcGFjaW5nPSIwcHgiIHdvcmQtc3BhY2luZz0iMHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSIzNzMuOTkzMDQiIHk9Ijk0NC4zNTc1NCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPk1hY0FydGh1cjwvdHNwYW4+PC90ZXh0PgogIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNzgwLjg0NjA3IiB5PSItNDkwLjI0NTk3IiBmaWxsPSIjMDAwMDAwIiBmb250LWZhbWlseT0iVmVyZGFuYSIgbGV0dGVyLXNwYWNpbmc9IjBweCIgd29yZC1zcGFjaW5nPSIwcHgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9Ijc4MC44NDYwNyIgeT0iLTQ5MC4yNDU5NyIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPlNlbmVjYTwvdHNwYW4+PC90ZXh0PgogIDxwYXRoIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgMjg3LjM2KSIgZD0ibTM2Ny43IDUzNy4yMSAxNDEuMjgtMS4wMTAyYzYuNDktMC4wNDY0IDEyLjc4MSA3LjIzNTQgMTkuMTkzIDcuMzIzNiA1NS45MjQgMC43Njg5IDE1OC42OS0wLjE3MzMzIDIzNi41MS0xLjAxMDIgNy44Mzk2LTAuMDg0MyAyMi42MzEtMTkuODU0IDMwLjMwNS0yMC40NTYgMjIuMjY2LTEuMzUxOCA0NS4xNzktMC41MDUwNyA2Ny42OC0wLjUwNTA3IDE2LjE0Ny0wLjYzMjQxIDMuNjEwMiAyMC43MDggMjYuNzY5IDIwLjcwOGwyNDMuNDUtMS4wMTAyIiBjb2xvcj0iIzAwMDAwMCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogIDx0ZXh0IHg9IjY4NS4yMDgxMyIgeT0iODI3LjUzMDgyIiBmaWxsPSIjMDAwMDAwIiBmb250LWZhbWlseT0iVmVyZGFuYSIgbGV0dGVyLXNwYWNpbmc9IjBweCIgd29yZC1zcGFjaW5nPSIwcHgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjY4NS4yMDgxMyIgeT0iODI3LjUzMDgyIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+UGF3bmVlPC90c3Bhbj48L3RleHQ+CiAgPHBhdGggdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAyODcuMzYpIiBkPSJtNTU0LjI5IDcyMS40My00LjI4NTctMTc4LjIxLTIuODU3MS00NDAuNzEtMC4zNTcxNC03OS4yODYiIGNvbG9yPSIjMDAwMDAwIiBmaWxsPSJub25lIiBzdHJva2U9IiMzMzYiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI1MjkuNjI1MzEiIHk9Ii01NTAuODQ3NzgiIGZpbGw9IiMwMDAwMDAiIGZvbnQtZmFtaWx5PSJWZXJkYW5hIiBsZXR0ZXItc3BhY2luZz0iMHB4IiB3b3JkLXNwYWNpbmc9IjBweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTI5LjYyNTMxIiB5PSItNTUwLjg0Nzc4IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+QnJvYWR3YXk8L3RzcGFuPjwvdGV4dD4KIDwvZz4KPC9zdmc+Cg==", "public": true - }, - { - "link": "/api/images/system/map_marker_image_0.png", - "title": "Map marker image 0", - "type": "IMAGE", - "subType": "IMAGE", - "fileName": "map_marker_image_0.png", - "publicResourceKey": "CdCrVxsjA4EAiFaXK4a7K2MZFMeEuGeD", - "mediaType": "image/png", - "data": "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", - "public": true - }, - { - "link": "/api/images/system/map_marker_image_1.png", - "title": "Map marker image 1", - "type": "IMAGE", - "subType": "IMAGE", - "fileName": "map_marker_image_1.png", - "publicResourceKey": "DF3fuPXua9Vi3o3d9Nz2I1LXDTwEs2Tv", - "mediaType": "image/png", - "data": "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", - "public": true - }, - { - "link": "/api/images/system/map_marker_image_2.png", - "title": "Map marker image 2", - "type": "IMAGE", - "subType": "IMAGE", - "fileName": "map_marker_image_2.png", - "publicResourceKey": "rz5SFAw2Sg5T2EyXNdwLycoDwf4QbMiZ", - "mediaType": "image/png", - "data": "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", - "public": true - }, - { - "link": "/api/images/system/map_marker_image_3.png", - "title": "Map marker image 3", - "type": "IMAGE", - "subType": "IMAGE", - "fileName": "map_marker_image_3.png", - "publicResourceKey": "KfPfTuvKCeAnmTcKcrvZQHfdU0TPArWY", - "mediaType": "image/png", - "data": "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", - "public": true } + ], + "scada": false, + "tags": [ + "plan", + "zone", + "parking", + "location", + "coordinates", + "indoor", + "image", + "marker", + "geofence", + "placement", + "polygon", + "circle", + "layer", + "tiles", + "building", + "interior", + "venue", + "inside", + "room", + "office", + "manufacturing", + "floor", + "plant", + "storage", + "warehouse", + "depot" ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/image_map_deprecated.json b/application/src/main/data/json/system/widget_types/image_map_deprecated.json new file mode 100644 index 0000000000..23cf7f7a14 --- /dev/null +++ b/application/src/main/data/json/system/widget_types/image_map_deprecated.json @@ -0,0 +1,102 @@ +{ + "fqn": "maps_v2.image_map", + "name": "Image Map", + "deprecated": true, + "image": "tb-image;/api/images/system/image_map_system_widget_image.png", + "description": "Displays the indoor or relative location of the entities on the image map. Useful to display floor maps, smart parking, etc. Entity coordinates are expected to be in the range from 0 to 1. Highly customizable via custom markers, marker tooltips, and widget actions. ", + "descriptor": { + "type": "latest", + "sizeX": 8.5, + "sizeY": 6.5, + "resources": [], + "templateHtml": "", + "templateCss": ".leaflet-zoom-box {\n\tz-index: 9;\n}\n\n.leaflet-pane { z-index: 4; }\n\n.leaflet-tile-pane { z-index: 2; }\n.leaflet-overlay-pane { z-index: 4; }\n.leaflet-shadow-pane { z-index: 5; }\n.leaflet-marker-pane { z-index: 6; }\n.leaflet-tooltip-pane { z-index: 7; }\n.leaflet-popup-pane { z-index: 8; }\n\n.leaflet-map-pane canvas { z-index: 1; }\n.leaflet-map-pane svg { z-index: 2; }\n\n.leaflet-control {\n\tz-index: 9;\n}\n.leaflet-top,\n.leaflet-bottom {\n\tz-index: 11;\n}\n\n.tb-marker-label {\n border: none;\n background: none;\n box-shadow: none;\n}\n\n.tb-marker-label:before {\n border: none;\n background: none;\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.map = new TbMapWidgetV2('image-map', false, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-map-widget-settings-legacy", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"xPos\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.05427416942713381,\"funcBody\":\"var value = prevValue || 0.2;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"yPos\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.680594833308841,\"funcBody\":\"var value = prevValue || 0.3;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.9430343126300238,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.1784452363910778,\"funcBody\":\"return \\\"colorpin\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]},{\"type\":\"function\",\"name\":\"Second point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"xPos\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.05012157428742059,\"funcBody\":\"var value = prevValue || 0.6;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"yPos\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.6742359401617628,\"funcBody\":\"var value = prevValue || 0.7;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.773875863339494,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#3f51b5\",\"settings\":{},\"_hash\":0.405822538899673,\"funcBody\":\"return \\\"thermometer\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"provider\":\"image-map\",\"mapImageUrl\":\"tb-image;/api/images/system/image_map_system_widget_map_image.svg\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"xPosKeyName\":\"xPos\",\"yPosKeyName\":\"yPos\",\"defaultCenterPosition\":\"0,0\",\"disableScrollZooming\":false,\"disableDoubleClickZooming\":false,\"disableZoomControl\":false,\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"mapPageSize\":16384,\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"posFunction\":\"return {x: origXPos, y: origYPos};\",\"draggableMarker\":false,\"showLabel\":true,\"useLabelFunction\":false,\"label\":\"${entityName}\",\"showTooltip\":true,\"showTooltipAction\":\"click\",\"autocloseTooltip\":true,\"useTooltipFunction\":false,\"tooltipPattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See advanced settings for details\",\"tooltipOffsetX\":0,\"tooltipOffsetY\":-1,\"color\":\"#fe7569\",\"useColorFunction\":true,\"colorFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'colorpin') {\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120 * 100;\\n\\t return tinycolor.mix('blue', 'red', percent).toHexString();\\n\\t}\\n\\treturn 'blue';\\n}\\n\",\"useMarkerImageFunction\":true,\"markerImageSize\":34,\"markerImageFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'thermometer') {\\n\\tvar res = {\\n\\t url: images[0],\\n\\t size: 40\\n\\t}\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120;\\n\\t var index = Math.min(3, Math.floor(4 * percent));\\n\\t res.url = images[index];\\n\\t}\\n\\treturn res;\\n}\",\"markerImages\":[\"tb-image;/api/images/system/map_marker_image_0.png\",\"tb-image;/api/images/system/map_marker_image_1.png\",\"tb-image;/api/images/system/map_marker_image_2.png\",\"tb-image;/api/images/system/map_marker_image_3.png\"],\"showPolygon\":false,\"polygonKeyName\":\"perimeter\",\"editablePolygon\":false,\"showPolygonLabel\":false,\"usePolygonLabelFunction\":false,\"polygonLabel\":\"${entityName}\",\"showPolygonTooltip\":false,\"showPolygonTooltipAction\":\"click\",\"autoClosePolygonTooltip\":true,\"usePolygonTooltipFunction\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"polygonColor\":\"#3388ff\",\"polygonOpacity\":0.2,\"usePolygonColorFunction\":false,\"polygonStrokeColor\":\"#3388ff\",\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":3,\"usePolygonStrokeColorFunction\":false,\"showCircle\":false,\"circleKeyName\":\"perimeter\",\"editableCircle\":false,\"showCircleLabel\":false,\"useCircleLabelFunction\":false,\"circleLabel\":\"${entityName}\",\"showCircleTooltip\":false,\"showCircleTooltipAction\":\"click\",\"autoCloseCircleTooltip\":true,\"useCircleTooltipFunction\":false,\"circleTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"circleFillColor\":\"#3388ff\",\"circleFillColorOpacity\":0.2,\"useCircleFillColorFunction\":false,\"circleStrokeColor\":\"#3388ff\",\"circleStrokeOpacity\":1,\"circleStrokeWeight\":3,\"useCircleStrokeColorFunction\":false},\"title\":\"Image Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}" + }, + "tags": [ + "building", + "interior", + "venue", + "inside", + "room", + "office", + "manufacturing", + "floor", + "plant", + "storage", + "warehouse", + "depot" + ], + "resources": [ + { + "link": "/api/images/system/image_map_system_widget_image.png", + "title": "\"Image Map\" system widget image", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "image_map_system_widget_image.png", + "publicResourceKey": "hDdSISQr6elribOYD6T3uePXZI5WvNtM", + "mediaType": "image/png", + "data": "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", + "public": true + }, + { + "link": "/api/images/system/image_map_system_widget_map_image.svg", + "title": "\"Image Map\" system widget map image", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "image_map_system_widget_map_image.svg", + "publicResourceKey": "QKRIYhDeBGwjaeIS601VvNLSsvZ25DRj", + "mediaType": "image/svg+xml", + "data": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTEzNC41IiBoZWlnaHQ9Ijc2Mi43OCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTI3LjA3MSAtMzA3LjkpIj4KICA8ZyBmaWxsPSJub25lIj4KICAgPHBhdGggZD0ibTkwNi4wMyA3MDYuMTMgMy40MjkyIDE3Ljc5Nm0tODgwLjg5IDQxLjEyMWMxNTAuNDQgNi44MzM0IDE0Ni4zOS0yNi4zMzQgMTY2LjQzLTI5LjMyIDM2LjE0NC01LjM4NDggMTE0LjI5LTYuNTI1NCAxNDguMzMtOC42MjM1IDQzLjM3OC0yLjY3MzggMTQxLjc2LTExLjIzMSAxODguODYtMTkuODM0IDM5LjgxMS03LjI3MjggMjIxLjM3LTAuODYyMzUgMzE5LjA3LTAuODYyMzUgNzAuODI3IDAgMTQ2LjkyLTEuNzI0NyAyMTguMTgtMS43MjQ3LTMxLjYyIDAgMTE3Ljg2LTIuNTg3MSA4Ni4yMzYtMi41ODcxbS0yNS4wOTEtNjguMTI2Yy01Mi44IDM0Ljc4NS02NS44OTUgNTEuNzQ5LTk1LjYzOSA4MS40OTMtMjQuOTMxIDI0LjkzMS0xNDAuNC0xOS4xMzktMTc4Ljk0IDM2LjY1LTEyLjI4MSAxNy43NzctNDcuMDAzIDQ2LjU0Ny02NS4xMDggNTkuMDcxLTIwLjEwNSAxMy45MDgtNTYuMDM3IDQ0Ljk1Ny02Ny43NjkgNzMuMDc4LTQuODAxNSAxMS41MDktMTMuMzggMzUuOTkzLTIzLjQ0OSA0Ni4wNjItMTAuNDk3IDEwLjQ5Ny0zOC4zNzcgNi4zODU3LTQ0LjAyMyAxNy42NDgtMTkuMDA1IDM3LjkwOC0yNS40NjUgMTAwLjkyLTY3LjYxOCAxMDIuMDVtMTkuMjgyLTYyNC4wMWMzNC42NTktMS44NzM4IDg0LjAyNyA3LjM5MTMgMTA5LjktNC4yODU0IDEzLjI4Mi01Ljk5NDEgNDEuNDA3LTIuNDYxNCA2Ni44MjktMi4zMjA1IDM1LjMyMiAwLjE5NTc4IDY0LjM4MiAwLjYzNDc3IDEwMS45MiA1LjAyMzIgMjUuMDMgMi45MjY1IDQ0LjY2MyAzNC4yODcgNTguNTI3IDUwLjY0NCAxNy4wOTkgMjAuMTczIDYyLjc2NC0xLjcxNDcgNjYuMzA2IDMyLjEzNCA1LjEwMjcgNDguNzY2LTYuMzI4NCA3OC42MzcgNi4xNDExIDk3LjM0MiAxOS45NjkgMjkuOTU0IDUwLjQ4NiAxNy44NTYgNDQuNjE5IDgzLjk3MW0tNDcyLjQ1LTM3OC43OWM0LjY0MzUgMjMuNzI5IDE1LjA2OSA3Mi43NzYgMTkuMDYxIDEzMC42NCAwLjg3MjA2IDEyLjY0IDUuNDQ3MiAyNC45OTMgNC4yMjIzIDQ1LjI3OC0yLjUxNzIgNDEuNjg4LTE1LjcxNyA0My42NzctMTUuMDkxIDYwLjM2NSAxLjQzMiAzOC4xODIgMzAuNjE0IDkzLjgzNyAzMC42MTQgMTM5LjcgMCAyNC4xODEtMi42Njk2IDExNS4zOSA3LjMzIDEzNS4zOSAwLjE1OTExIDAuMzE4MjEgMTAuMDY1IDM1Ljg4MyAxMC43NzkgNDkuMTU0IDAuOTQzNzggMTcuNTI1LTI0LjQ3OCAzOS40Ny0yOC4wMjcgNDYuNTY3LTUuNDc3NyAxMC45NTUtMzYuOTczIDEwLjg4Mi00MC4xIDI0LjE0Ni0zLjg2ODggMTYuNDE1LTMuODY2MyA0My43OTcgNC4wNDY1IDU5LjQ0MW05Ny4zMzctNjkxLjAxYy01LjAxMzMgMzUuNTE2LTQzLjY1OSAxMS4zMTctNTguNTM5IDIzLjc4MS0yMS4zMyAxNy44NjktNjIuNSAzMS40MzItNzAuMTI0IDM1LjM2Ny0zNS4wODggMTguMTA4LTExMC40Ny0xNS4xNDItMTI1LjYxIDQuMjY4NC0xNS45NTEgMjAuNDQ3LTAuMDczNSA2MS40NjYtOS4xNDY3IDg0LjE0OS02LjAzNTcgMTUuMDg5LTE4Ljg3NyAyMy4wMTctMjcuNDQgMzIuOTI4LTE5Ljc0OCAyMi44NTYtNjkuOTc0IDY5LjgyNC04NC43NTkgMTAwLTcuNDk3NCAxNS4zMDQtMy4yODQzIDQ0LjQyLTMuNDcwNSA2My4zNDMtMC4xMjc5MyAxMi45OTQtMC44MTAxNSAyMy4xMDQgMi40MDM0IDI4LjI3NiA0Ljk2MTYgNy45ODU4IDIzLjcyIDI4LjExMiAyNC4yMzkgNTAuNjExIDAuMjk0MTEgMTIuNzcxIDAuMDEzMyA3OC41OTEgMy4wNDg5IDg3LjY1NSAyLjMxMjYgNi45MDU1IDQuMjIgMjYuNTY1IDEwLjIxNCAzNi41ODcgMTEuMzU0IDE4Ljk4NCA0LjM4NzQgNDAuMTU3IDI3Ljg5NyA1My41MDggMTkuMDUgMTAuODE5IDQ2Ljg3OCAxMi4yMTkgODEuOTI2IDE0LjQ2MSAzMy43MDMgMi4xNTU5IDYxLjUxMi0xLjQzMDQgNzYuOTIxIDYuMTQxMSAxMS41ODUgNS42OTI3IDguNTgxNSAxNy45MzMgMTQuMjk1IDI5LjM2MSA1LjY0MDQgMTEuMjgxIDMxLjUwMyAxMS4xNTYgNDEuODA0IDQzLjQ1NSA3LjYwNTkgMjMuODQ3IDMuMDg1OSA0NC4xNTcgNi43MDc2IDY1Ljg4NyIgc3Ryb2tlPSIjMzY0ZTU5IiBzdHJva2Utd2lkdGg9IjMiLz4KICAgPHBhdGggZD0ibTQzLjI3OCA1MTcuOTVzMjMwLjg1LTMuNjM4IDI1MC4wMS0zLjY1ODdjNy40ODIyLThlLTMgOC42MTk1IDUuMTUxOSAxNC4wMjEgMTEuNDU5IDI0LjU5NiAyOC43MTkgOTMuOTEgMTEyLjk0IDkzLjkxIDExMi45NCIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogICA8cGF0aCBkPSJtMzUuOTYxIDU3Ny43czE2NS41Mi0xLjY4NDUgMjQ4Ljc4LTEuNjg0NWM0Ljk0NzUgMCA3LjcyOTktMi44ODMzIDEwLjUzOC01LjcyOTggOS42NjExLTkuNzk0MiAyNS42MzItMjguNTkgMjUuNjMyLTI4LjU5IiBzdHJva2U9IiMzMzYiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgPC9nPgogIDxwYXRoIGQ9Im0zOC40IDY0MS43MyAzOTMuMzEtNC4yNjg0IiBjb2xvcj0iIzAwMDAwMCIgZmlsbD0iIzMzNiIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogIDxwYXRoIGQ9Im0zOS4wMDkgNzA0LjU0IDQ4NC4xNi02LjcwNzYiIGNvbG9yPSIjMDAwMDAwIiBmaWxsPSIjMzM2IiBzdHJva2U9IiMzMzYiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzM2Ij4KICAgPGcgc3Ryb2tlLXdpZHRoPSIxcHgiPgogICAgPHBhdGggZD0ibTMwMy45NiA2ODIuNTkgMTQ2LjggMS44MjkzYzEwLjUzNCAwLjEzMTI3IDE0LjM0NC0yLjYzNzQgMjUuNDg3LTYuMzcyOCAxMC40MTItMy40OTAzIDMxLjQyNC0yLjY5OSA0MS4zODUtMi43NzM4bDQwNS41Ni0zLjA0ODkiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDI2LjIyIDMxNC44OWMyLjA2NzUgOS4wNTI3IDEuODQxOCA1MS43MjggNi41MDc5IDc0LjgzNSAxLjY3NDggOC4yOTM0IDguNjc1MSAxNC4wNjYgMTAuMDU1IDE0Ljg1OSA0LjkwMTUgMi44MTQ2IDEwLjgxNSA4LjE0OTggMTMuMDQ2IDE2LjA4OCA2Ljc1NzggMjQuMDQ2IDAuODc5NzIgNjguNDUyIDAuODc5NzIgMTEwLjY5IDAgNi4wOTc4IDEuNjYwMSAzMC4xNDctMi4xNTU5IDMzLjk2My0yLjU0MDggMi41NDA4LTAuMjgxNjMgMTIuOTkxLTMuNDM2OCAxNi4xNDRsLTkuODQ5NCA5Ljg0MzFjLTEwLjM2NyAxMC4zNi0xMS41OSA2LjUyNjEtMTcuNzM4IDE4LjgyMy0zLjU2NzcgNy4xMzU0IDUuNDAyNCAyMC42NzIgNy4zNTQzIDI0LjU3NiAxLjkzMjEgMy44NjQzLTEuODQyMiA0Ljc3NzctMS43OTI0IDcuNDQ2MyAwLjI1Mjg2IDEzLjU0NSAyLjI5NzUgMzczLjkzIDIuMjk3NSAzNzMuOTMiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtMzY1LjI0IDUxOS43OCA0LjExNiA1MDIuMTUiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtMTE2LjUzIDUwNC4xOSAzLjg4MDYgMzEwLjk2IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTMxNy42OCA1NzYuNDkgMTMwLjE5IDEuNTI0NGM0LjUxMDggMy4yNDE3IDIwLjM0NSA3Ljk2ODUgMjcuNzQ1IDQuMjY4NCAzLjE1NTUtMS41Nzc3IDkuNDE5LTUuMzg4MiAxNC4wMjUtMy45NjM2IDQuMjY3IDEuMzE5OCA2LjAxNjkgMy4xMTYzIDEwLjM2NiAzLjA0ODkgMTAuMzA0LTAuMTU5NzUgMjAuMjEyIDAuMzg3NDEgMzAuNDg5IDAuMzA0ODkgMTc3Ljg5LTEuNDI4MyAzNTYuNTktMi4xMzI1IDUzNC43Ny0zLjA0ODkiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDc1LjMxIDU4Mi44OWMtMy40NDQyIDExLjM1MS0yLjEwMzQgMTIuNDM0IDMuNjU4NiAyMS4wMzcgMy43OTQ0IDUuNjY1NiA1MC44NjMgMTMuMDM4IDQxLjQ2NSAyNy4xMzUtMTAuNTM3IDE1LjgwNS0yMi44OTctNS40Nzc3LTMzLjg0My0xLjgyOTMtNS40NTI0IDEuODE3NC03LjM0OSA1LjQ1NjMtMy42NTg3IDkuMTQ2NiAyLjgwNjggMi44MDY4IDQuMDQ4IDEuODA0IDYuNTIwMyA1LjEwMDQiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDMyLjAxIDYzNi44NWM4LjMxOSAxMy4xMSAxOC44NDYgMTQuNjM1IDM1LjY3MiAxNC42MzUgMi45Mzg2IDAgNy44Ny0wLjkzMzcxIDEwLjY3MSAwIDExLjM1OSAzLjc4NjQgMjcuMTk0IDEwLjI3NiAzNi4yMDIgMjEuMTI5IDguMjggOS45NzY2IDEwLjI1MyAyMy44ODMgNy43MDIgMzcuMTA0LTYuMTY5OSAzMS45OC0xNi43MTQgNTYuOTg5LTE5LjA0NCA4Ni41NjktMS4zNDggMTcuMTE5IDQuNTA5NiAyMi41MzUgMTEuMDcxIDMzLjkyOSAxMC42NyAxOC41MjcgOC43MjQ1IDE0LjIgOC41NzE0IDM0LjI4Ni0wLjEzOTYzIDE4LjMxOSAwIDYwLjI2NCAwIDgwLjcxNCIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MjguNTEgNjU4Ljk2Yy0xMC42ODEgMC45MDQ1NC03LjEwOC01LjYwMjYtMTAuODI0LTguMDc5Ni00Ljc4NDUtMy4xODk3LTEyLjIyNy0xLjI1MS0xNi43NjktNS43OTI5LTAuNjY2MTItMC42NjYxMi04LjgwOTctNC4xMDg4LTEwLjE3NC0yLjc0NC04LjM2NDYgOC4zNjQ2LTMuMDQ4OSAyMC41NTItMy4wNDg5IDMzLjUzOGwzLjAyMiAzMzkuNyIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MTcuOTkgNjUxLjAzYy0wLjIyMTcxLTIuNzAxOCAxLjkwMzUtNS41NjIxIDMuMzUzOC03LjAxMjQgMS43OTk0LTEuNzk5NCA2LjkyMjkgMS4wMDQyIDguODQxOC0wLjkxNDY2IDAuMjg3NjUtMC4yODc2NiAwLjg0MzI5LTExLjE2NCAwLjIyODY2LTEzLjU2OC0yLjA2NDgtOC4wNzQyLTIuMDU4LTI4LjY1Ny0yLjA1OC0zOC43MjF2LTczLjE3MyIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MjguNjYgNjc1LjQyLTAuNDU3MzMtMzEuNTU2IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTc2Ni4zMiA1NzkuNjQgMC40MzExOCAxMy43OThjMy4xMzY0IDQuNjY5MiAzLjAxODIgOS42MDA3IDMuMDE4MiAxNi4zODV2MTU3LjM4IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTExMjIuOSA3NjUuOTFjLTIwMi4zMSA0LjY5MDUtNDAzLjc0LTEuMTEzOC02MDUuOTUgMy4zNTM5LTEwLjg2NCAwLjI0MDAyLTMuMzYxNS04LjU4NjMtMjguNTM3LTguNTg2MyIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im04NjAuMDEgNzM3LjA3cy05Ny40NDggMC44NTgwNi0xNDcuNTcgMC44NTgwNmMtNS4yNjg2IDAtNC41MTU1LTguMzI5OS03LjMwMDktOC4zMjk5LTMuOTc0NCAwLTguNjI5MiAwLjAyMDEtMTAuNTA5IDAuMDM1OS0yLjMzNDggMC4wMTk3LTEuODEwOSA4LjM2Ni00LjE0NTggOC4zNjY5LTQ2LjE2OSAwLjAxODgtMTY3LjQxLTEuMzA4LTE3NS4wNS0xLjMwOC00LjQyOTYgMC04LjU3NjMtNi40Mzk3LTEzLjEzMi02LjQzOTdoLTE0LjM5NSIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im02NzUuMDEgODMxLjE3LTAuNjA5NzgtNTIxLjc3IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTc5OS40IDMxMy4wNiAxLjIxOTYgNDk1Ljg3IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTczNi41OSAzMTIuNDUtMS4yMTk2IDcxNi40OSIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MzAuMDMgNjQzLjQ2IDM5Mi4zNy0zLjAxODIiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtODU5LjQ1IDMxNC45IDEuMjkzNSA1MDcuOTgiIGNvbG9yPSIjMDAwMDAwIi8+CiAgIDwvZz4KICAgPHBhdGggZD0ibTkyMS41NCAzMTAuNTkgMS43MjQ3IDUzMS43NSIgY29sb3I9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgIDxnIHN0cm9rZS13aWR0aD0iMXB4Ij4KICAgIDxwYXRoIGQ9Im03MzYuMjkgNDUzLjMxIDE4NS42OC0wLjMwNDg5IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTEwNjAuOCA1MTQuOTdzLTM2My4yOC01LjYyNjItNTQ0LjY1IDIuNTIxOGMtNC4xNzc4IDAuMTg3NjktMTIuNSAxLjA2NzEtMTIuNSAxLjA2NzEtMS41NzEgMC4xMzQxLTIuMDAwOS0yLjMyNS0yLjU5MTYtMy41MDYyLTAuMDk2Ny0wLjE5MzQzLTcuMDYwOC0xLjkzMzQtNy42MjIyLTEuMzcyLTIuODkzMSAyLjg5MzEtNy42MzE3IDQuMjQ4Ny0xMi4xOTYgNC4xMTZsLTExMi4wNS0zLjI1NzgiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtMzk5LjgyIDQ3OS42MSAxMS42NDIgNS42MDUzYzIuOTg0MSAxLjQzNjggNi41Mjg4LTAuNDc3MTIgOS45MTcxLTAuNDMxMThsMTI3LjIgMS43MjQ3IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTUxOS4yNSA1MTcuMTItMC40MzExOS0yMDguNjkiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDMyLjkzIDM4OS43MWMxMS4wNDUgMCAzNS41MzMgMC42MTkyNyA0Mi41OC0xLjAwNCA4LjQwNTItMS45MzYyIDcuMDY2LTYuOTUzOCAxNC4xOTctNi45NTM4IDcuODA5NSAwIDYuNTQyOSA4LjA2MjQgMjAuMTQyIDguMDYyNCAxMy45OTEgMCA0NC45NzcgMC4zNzg4NiA2My45NCAwLjM3ODg2IDEyLjA4NCAwIDgyLjAwMyAwLjMwNDg5IDkzLjYwMSAwLjMwNDg5IDguNzYwNSAwIDEzLjE2LTIuMjg4MyAyMS4zNDItNy4wMTI0IDcuMTk1Mi00LjE1NDEgMi4wNTQ2LTkuNDkxNCAyMC40MjgtOC44NDE4IDIzLjE0NSAwLjgxODMzIDEyLjY0MyAxNC4wMjUgMzIuMzE4IDE0LjAyNWgxNTAuOTJjMTQuMzMyIDAtNC4xMTkxLTEzLjExIDI5LjI2OS0xMy40MTUiIGNvbG9yPSIjMDAwMDAwIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgZmlsbD0iIzAwMDAwMCIgZm9udC1mYW1pbHk9IlZlcmRhbmEiIGxldHRlci1zcGFjaW5nPSIwcHgiIHdvcmQtc3BhY2luZz0iMHB4Ij4KICAgPHRleHQgeD0iNTg4LjY3OTU3IiB5PSI3MzUuODA0NjMiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU4OC42Nzk1NyIgeT0iNzM1LjgwNDYzIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+TGluY29sbjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI2ODYuMzk4NSIgeT0iNzY1LjYyODQyIiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI2ODYuMzk4NSIgeT0iNzY1LjYyODQyIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+SGFycnk8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI3MDkuODcxODMiIHk9Ii04MDIuMzc3MzgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjcwOS44NzE4MyIgeT0iLTgwMi4zNzczOCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldvb2RsYXduPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNTYyLjExOTI2IiB5PSItNzcxLjk2ODE0IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1NjIuMTE5MjYiIHk9Ii03NzEuOTY4MTQiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5FZGdlbW9vcjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjU5OC4zMDQ4NyIgeT0iLTczOC4zNjY0NiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTk4LjMwNDg3IiB5PSItNzM4LjM2NjQ2IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+T2xpdmVyPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNTkyLjEyMjg2IiB5PSItNjc3LjIwMzk4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1OTIuMTIyODYiIHk9Ii02NzcuMjAzOTgiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5IaWxsc2lkZTwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjU5Ny4zMjcwOSIgeT0iLTg2Mi42MTQwNyIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTk3LjMyNzA5IiB5PSItODYyLjYxNDA3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+Um9jazwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjU4Ny4zNzAxOCIgeT0iLTkyNi4xMzY2IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1ODcuMzcwMTgiIHk9Ii05MjYuMTM2NiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldlYmI8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iODcxLjE2MTAxIiB5PSI2MzcuNTc1MiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iODcxLjE2MTAxIiB5PSI2MzcuNTc1MiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPkNlbnRyYWw8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iODczLjgzMjI4IiB5PSI1NzcuMDMyNDciIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9Ijg3My44MzIyOCIgeT0iNTc3LjAzMjQ3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+MTN0aDwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI4NzUuOTY2NDkiIHk9IjUxMC4yNjE4MSIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iODc1Ljk2NjQ5IiB5PSI1MTAuMjYxODEiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij4yMXN0PC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9Ijg4MS4zMTY1OSIgeT0iNDUwLjE5ODc2IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI4ODEuMzE2NTkiIHk9IjQ1MC4xOTg3NiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjI5dGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iNjE1Ljc5MjQ4IiB5PSIzODcuNzQ3MTYiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjYxNS43OTI0OCIgeT0iMzg3Ljc0NzE2IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+Mzd0aDwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI0ODQuNjkwMzciIHk9IjQ4MS42NTI4NiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNDg0LjY5MDM3IiB5PSI0ODEuNjUyODYiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij4yNXRoPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjU2My4wNDY3NSIgeT0iNTEzLjM2MTMzIiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1NjMuMDQ2NzUiIHk9IjUxMy4zNjEzMyIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjIxc3Q8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iNTY1Ljk3MTUiIHk9IjU3Ny44OTQ4NCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTY1Ljk3MTUiIHk9IjU3Ny44OTQ4NCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjEzdGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI0MzMuNTgwNzUiIHk9Ii00NjAuNzMzMTIiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjQzMy41ODA3NSIgeT0iLTQ2MC43MzMxMiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPkFtaWRvbjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjQwNS41MzA5OCIgeT0iLTUyMy41NDAxNiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNDA1LjUzMDk4IiB5PSItNTIzLjU0MDE2IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+QXJrYW5zYXM8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI3NDUuNDg0NjIiIHk9Ii0zNzIuNTg1OTQiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9Ijc0NS40ODQ2MiIgeT0iLTM3Mi41ODU5NCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldlc3Q8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI1OTYuNzI4MzMiIHk9Ii01MzEuMjU5MjgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU5Ni43MjgzMyIgeT0iLTUzMS4yNTkyOCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldhY288L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI1OTUuNDM0ODEiIHk9Ii0xMjIuNTAyOTUiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU5NS40MzQ4MSIgeT0iLTEyMi41MDI5NSIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPk1hemllPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDQ1KSIgeD0iNjk1Ljc3Mjk1IiB5PSIxNjIuMDY4NzciIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjY5NS43NzI5NSIgeT0iMTYyLjA2ODc3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+Wm9vPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjI0MC41ODk5NyIgeT0iNTc0LjQ0NTQzIiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSIyNDAuNTg5OTciIHk9IjU3NC40NDU0MyIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjEzdGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iMjA2LjAzMTc1IiB5PSI1MTEuNjM2NjMiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjIwNi4wMzE3NSIgeT0iNTExLjYzNjYzIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+MjFzdDwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjYyMC40NDMxMiIgeT0iLTUwNi42ODIxOSIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNjIwLjQ0MzEyIiB5PSItNTA2LjY4MjE5IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+TmltczwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSIzNzAuMjE2ODYiIHk9IjY5OC44NDAwOSIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iMzcwLjIxNjg2IiB5PSI2OTguODQwMDkiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5NYXBsZTwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSIzODQuMDg0MiIgeT0iNjgwLjg1MTM4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSIzODQuMDg0MiIgeT0iNjgwLjg1MTM4IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+RG91Z2xhczwvdHNwYW4+PC90ZXh0PgogIDwvZz4KICA8cGF0aCBkPSJtMzY3LjkxIDEwMTBoMjYzLjAyIiBjb2xvcj0iIzAwMDAwMCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogIDxnIGZpbGw9IiMwMDAwMDAiIGZvbnQtZmFtaWx5PSJWZXJkYW5hIiBsZXR0ZXItc3BhY2luZz0iMHB4IiB3b3JkLXNwYWNpbmc9IjBweCI+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNzM2LjI2NzQ2IiB5PSItNDMzLjEzNzc2IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI3MzYuMjY3NDYiIHk9Ii00MzMuMTM3NzYiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5NZXJpZGlhbjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI1NzIuODMyMTUiIHk9IjY0MC4yMDUyNiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTcyLjgzMjE1IiB5PSI2NDAuMjA1MjYiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5DZW50cmFsPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjU3NS4wODk2NiIgeT0iNjcwLjkwMzUiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU3NS4wODk2NiIgeT0iNjcwLjkwMzUiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5Eb3VnbGFzPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjQ5OS40ODk2MiIgeT0iMTAwOC42MDY5IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI0OTkuNDg5NjIiIHk9IjEwMDguNjA2OSIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjQ3dGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iMjE2LjY0NTQzIiB5PSI3MjUuOTgyOTciIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjIxNi42NDU0MyIgeT0iNzI1Ljk4Mjk3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+S2VsbG9nZzwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9Ijc3NC44NzU2MSIgeT0iLTUwOC4xODk3MyIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNzc0Ljg3NTYxIiB5PSItNTA4LjE4OTczIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+TWNDbGVhbjwvdHNwYW4+PC90ZXh0PgogIDwvZz4KICA8cGF0aCB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIDI4Ny4zNikiIGQ9Im0zNjQuMTYgNjU4LjQzIDI5OS41MS0xLjAxMDJjNi40OTg3LTAuMDIxOSA2Ljk3NzIgOS4yNTQxIDE2LjU5NiA5LjM5MjUgMTIuMDU0IDAuMTczMzkgMjkuMTExLTAuNTM1NzIgNTQuMTE0LTAuMzAxMSIgY29sb3I9IiMwMDAwMDAiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzMzNiIgc3Ryb2tlLXdpZHRoPSIxcHgiLz4KICA8dGV4dCB4PSIzNzMuOTkzMDQiIHk9Ijk0NC4zNTc1NCIgZmlsbD0iIzAwMDAwMCIgZm9udC1mYW1pbHk9IlZlcmRhbmEiIGxldHRlci1zcGFjaW5nPSIwcHgiIHdvcmQtc3BhY2luZz0iMHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSIzNzMuOTkzMDQiIHk9Ijk0NC4zNTc1NCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPk1hY0FydGh1cjwvdHNwYW4+PC90ZXh0PgogIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNzgwLjg0NjA3IiB5PSItNDkwLjI0NTk3IiBmaWxsPSIjMDAwMDAwIiBmb250LWZhbWlseT0iVmVyZGFuYSIgbGV0dGVyLXNwYWNpbmc9IjBweCIgd29yZC1zcGFjaW5nPSIwcHgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9Ijc4MC44NDYwNyIgeT0iLTQ5MC4yNDU5NyIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPlNlbmVjYTwvdHNwYW4+PC90ZXh0PgogIDxwYXRoIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgMjg3LjM2KSIgZD0ibTM2Ny43IDUzNy4yMSAxNDEuMjgtMS4wMTAyYzYuNDktMC4wNDY0IDEyLjc4MSA3LjIzNTQgMTkuMTkzIDcuMzIzNiA1NS45MjQgMC43Njg5IDE1OC42OS0wLjE3MzMzIDIzNi41MS0xLjAxMDIgNy44Mzk2LTAuMDg0MyAyMi42MzEtMTkuODU0IDMwLjMwNS0yMC40NTYgMjIuMjY2LTEuMzUxOCA0NS4xNzktMC41MDUwNyA2Ny42OC0wLjUwNTA3IDE2LjE0Ny0wLjYzMjQxIDMuNjEwMiAyMC43MDggMjYuNzY5IDIwLjcwOGwyNDMuNDUtMS4wMTAyIiBjb2xvcj0iIzAwMDAwMCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogIDx0ZXh0IHg9IjY4NS4yMDgxMyIgeT0iODI3LjUzMDgyIiBmaWxsPSIjMDAwMDAwIiBmb250LWZhbWlseT0iVmVyZGFuYSIgbGV0dGVyLXNwYWNpbmc9IjBweCIgd29yZC1zcGFjaW5nPSIwcHgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjY4NS4yMDgxMyIgeT0iODI3LjUzMDgyIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+UGF3bmVlPC90c3Bhbj48L3RleHQ+CiAgPHBhdGggdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAyODcuMzYpIiBkPSJtNTU0LjI5IDcyMS40My00LjI4NTctMTc4LjIxLTIuODU3MS00NDAuNzEtMC4zNTcxNC03OS4yODYiIGNvbG9yPSIjMDAwMDAwIiBmaWxsPSJub25lIiBzdHJva2U9IiMzMzYiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI1MjkuNjI1MzEiIHk9Ii01NTAuODQ3NzgiIGZpbGw9IiMwMDAwMDAiIGZvbnQtZmFtaWx5PSJWZXJkYW5hIiBsZXR0ZXItc3BhY2luZz0iMHB4IiB3b3JkLXNwYWNpbmc9IjBweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTI5LjYyNTMxIiB5PSItNTUwLjg0Nzc4IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+QnJvYWR3YXk8L3RzcGFuPjwvdGV4dD4KIDwvZz4KPC9zdmc+Cg==", + "public": true + }, + { + "link": "/api/images/system/map_marker_image_0.png", + "title": "Map marker image 0", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "map_marker_image_0.png", + "publicResourceKey": "CdCrVxsjA4EAiFaXK4a7K2MZFMeEuGeD", + "mediaType": "image/png", + "data": "iVBORw0KGgoAAAANSUhEUgAAAB4AAAB/CAYAAAD4mHJdAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAACWAAAAlgB7MGOJQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAwgSURBVGiB7Zt5cBT3lce/v18fc89oRoPEIRBCHIUxp2ywCAgIxLExvoidZIFNxXE2VXHirIO3aqtSseM43qpNeZfYKecox3bhpJykYgdjDkU2mBAB5vCamMNYAgQyURBCoxnNPd39O/aP7hGSEUR24L/uqqf+zfR77/Pe69/Rv6kWwcgPLRIJfZUAa7xez2xd90QBwDSNZKlkHJHAK+l09mUA7BP4vPpRUVExMVoRef+L998njxx9X57vPi/PnTsnO850yPaT7XLXrrflqjtWymhF+HA0Gp0wEp/kHymEQqG4ptJDGzf+um5RUxMSiV7Z3Lyt88L5nozgHJWj4pGmpqZav99PWve04onHHuswmViQzWb7ruZX+Udgv8/z3A+f/NGye1evxssvb+wo5PMfTZs6bfqcuXNHL7hlweh58+ZVAOTUpk2b0p9dvjyqqmrs/b8ejpUMc+unzjgUCsXjsYruE+2n1JY/NedM0zCi0VjA7/d7/f4AAgE//H4/vF4fOjvP9h5695C/oaEhcN/q1SyTzVdnMpnklXzTq4EplUsXfmaRCgC7du3cOn78+KfGj59Add3z1Md1vV7vqPa2D1sA4MYbZ6qUiqVX9X21i4TQcfX19QCA6urquN/vn0kAPRQKpYbTnzRpUhgAampqAEFrPjVYSql7fD4AgK5r2tV0AcDj8WkAoOk6JJGeTw2+nocLdsEu2AW7YBfsgl2wC3bBLtgFu2AX7IJdsAt2wS7YBbtgF+yCXbALdsEu2AW7YBfsgl2wC76mh/ppjIQgXVloPxVSBRV0rBe455P6+kTKBYF3tonxY/IWarry7DvI298Tgp0PR9RzACaN1NeIS100+EdvKXW3cMZvF8wCK10Sq2it2NAzakmukP/wmoP/KuId3BRUMg5uCfCSNVSKVn1rNto7Un8jLrUVqJ4Fi2eEQiEYBzOsy3SYL37TNQdzi8Q5FxkqJIQBsNLlYMGF/zqAJWBxSEogDAY+DJibYqTuRg4WFgO3OKhCYTExbKk5G/mbkSPP2DQhLA5IO/NhSz1MMP882BDgnAFQwdiVSs2vPVhYDIJLUMkBgw1favM6lJoZDDAYhKbAYsOX+rqAhcXAuQSIAKzhSy2vS8YmB7NYH4WCfM7kw5VaWtdpOO3bfWZJZVXgPxMX898bVsm6RhkTIseX29yyIErm/J5z5vwr6pvmsLYjBgeDwSpVJS/OmT1n1de+9qANZgLc4q9Dyj2qQhUhSSUAUCL7GBcchCymTEYBYNWqVXj30MGHT586PZEJ+WAul7ts8bjspd9QKDRNU2nz4z94YtI3H3oI+XwB//3j/9m77eRUUJ9/0eh4APGoDz6vCi4ksgUTmYyBC4k8RLGwtzF+EGu+tHqRqqrYtm0rXnzhhQ7G5cpsNnvyiuBIJFKnqvSd55772eilS5fhwIH9ye+/dPaEf1T9otW3T8GtiyYgGNBBymYEgLSbvakidu8/h01vnkYhcab1gcVs5tx5c6PHjh7DU0/9qFsINPb3939UZg28X11dXR0Qwtr9g8efqGtc+Bn89re/O7FhR9BXNaFm+n98uxHTZ1SDKQqKAihweZlITUVtXQwNs8fg+Bmzdk+bnmPdf/7bwsbGeO2ECaED+9/5XCxWuTGbzVpDwJpGNtx+28o77rr7bmzZsu3k7z+cMlHzeiPrvnoTwtVhFAVQHAZY4HBEoiAAeDXUjI/gyJGeQEd6TFj2tHYuXNgYy2azVe0fngiWDLNloHNFo4FZkXDsoTVr1+KD4x8U/3Ci1qP5PV7N74FeFUbClKDEriy57A5JANL5a68hnqoINL8OAPqbXbNp7clTxTVr1/oOHjr0MFXxq2Qy9wEFACnoY//6la9QAHj+9Q/eUL2RWkVXoWgqkhZBypRImkDKBFIWkLIk+h1JWdL+zrmeNCWSDFB0DYquQvWG637TcnozAKxbt45yTr8PAGowGBwVDAbvmT9/Pvbu3dddijV9WdUUUE0BUQm6kwaCYe+ljK/w8ruUdsYCBLlMEUQhoJoCygWM+LIvHTx4sGfevIbqYMD3BSFkJVUUrG5oaFABoPXwhd1UVUBVahtpKtoOnEV/gSHHgBwDso5c6XO6yNF24CNQTbV9qBRUUenuwz1/BoCZM2dplOJeSggWL1myFEII9IeXziIKBVUUW1QKo2Ci41Anei9kkWcY6Ex5R8qfc0wi0ZPF6QNnYeQNB2j7IQpFOtg0WwiBxoWNIBKLVQI6Z8rUqTh69FiWaFNmEIWgLFShoM5TZbIzgVxvFp6ID5rfA6JQgBAIxsGLJkrpAsycAcH4gN1gX0QPTW9vP5Grr58cJJTOpbqmjgWAnp6ei4QSEEJAKAGh1BbHCS2DLAFmMAgmICwObjDnyYMMAtJL9oN89vRc7KWUQtOUsSqhSggA8sWivSEh9qBxTiCEAGRwQARUVaB67Hf5pZAQlA0Ayrq2LTCogVyhlLURNEw55yYABP2+4ED3vHSClBKQ9jiFdHqvEBCMQzAOKYSt6/RqSGnbDPJRbgT93hAAcM4NyhjrBYDKylhswEEZJgYJFxDchnGTwSqasIomuMnsIDiH5GKIzUAQTsCVlZUxB9xLIUVbKpVEff3kiLTMfimEA7HP5bZgHMJ07mnJAiuaYEXT3jcZDMLkTgBD7exgBKRp9NfVTQwnk0kIKduoJGRH8/ZmhMNh4skc3DnEkDlAi4GbtjDDguVAmZM1M6yB68JyKsCGBqD373s7GAySnTt3gBDyFhWCvPHee/8HAJhTU5g0BMg4uMXBTT4AZSUTrGjBKpiwCnablQbDbZuyfTmAuRPMegA4euQopCRbaCaTOd2XSLzX3d2Nu+64bR7PnP3LJSCDMBm4YW9FWcmyQYMytsW+Zpfdsm1MdimAdMc7K29bMedCdzeSyeS76XT6jLNI4PGf/+w5aLqOu25IjOOWKcSg0jJjcLZ2ecsZD5TdybqsOxC0ZYpbJ58frek6nn/+eVBJHgecjXkqk2nu7Ozcdfz4cdx556rJN5C3m8v3jBt2xpdnazjysawNy5lUbKkrbmtZsWL5pGNHj6Or62+7k5lMy5CFNRQKTfN6tAMvvvhSRe3EOqx/4oXXLvia7qO6CsVZrey5154KB5YpKSG5tHs+5/ZsZnEIk6Ei1fLH73373i/09fXi0fWPpgyTLchkMqeGgAEgHA5/vjJWsf2PmzYr1dXV+K8fP7vjLxduWkY8ilpetQZPg+UJxh63lzqlNDi7gTa3fuPraz6bzxXw79/5FutP51am0+kdZdaQ/2kzDKNDUci51179w8pbP3er8sAD6+pnVCWy+/fs21LAqBnlMT50qJXFLq2a2L/5gaVy7N133j69u7sb67/7iFHIFf4tlU6/Ppg1kLGU8hYAywBMeOWV33gfXb9+1Q+ffDL+4Ne/AcYY/tS8PbV5++4Dhy+MopY2ZrLiidQDgDBSp5TS+Y7psS65ZOHsW26++eYosxje2PwGNm586eKzz/x027+sXWsBOAfgbULIQQAgUspaAA8BGAfnsamrq4u0tZ0Q333kkdGmZS3f8JNnlBXLV0AOilRKCS7sWYlxjlKxgHw+j5Y3W/C/Tz/NQ6Hgjp9seKZ31py5ajwe4wAtz9zdAH5OpJTPAqgEgL5USkpu4eLFHloqFXniYh9t3bunauuWrStisSi5//4vYnHTEkyZOhWqokBICcuy0N7ehr2trXjt1VeRzqTl3ffc81bjgsZELF4pQ6EAqa4eI6UEicfj5dhTKoCikynx6Bop5C14dJ2XcjmouipvvGFGoSJaWfr738/7tmzdjl/88pfIZjKwnH2SpmkIhSMYW1ODhvmNGFcztjhudFXR69Wgck58Hg+XEorH5ylDJYA8kVKOckpdB0ADIBOJhOzv70OhUFILuTzPZLNcSE6SfSlvJp0O5A1DN0qGDxLS4/OUAh6PGQqHC5XxeJEQgkgoRH1+L/wBP6LRuIjH4+Uf8gSAUwB+MbhzzQSwCMA0p/QUQADgNJ/PJ/v7+wnnnFiWkJZhKCYzKADoqiZUXeW67iGcSxKPx2QoFAo7AybnuE8COAZgHyHkxGXjeFAQEQCzANQCqAIQBeAH4AXgcex052w45TMcyQHIAOgBcBbAUUJI5uOM/wcaHmf3g9UM7QAAAABJRU5ErkJggg==", + "public": true + }, + { + "link": "/api/images/system/map_marker_image_1.png", + "title": "Map marker image 1", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "map_marker_image_1.png", + "publicResourceKey": "DF3fuPXua9Vi3o3d9Nz2I1LXDTwEs2Tv", + "mediaType": "image/png", + "data": "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", + "public": true + }, + { + "link": "/api/images/system/map_marker_image_2.png", + "title": "Map marker image 2", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "map_marker_image_2.png", + "publicResourceKey": "rz5SFAw2Sg5T2EyXNdwLycoDwf4QbMiZ", + "mediaType": "image/png", + "data": "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", + "public": true + }, + { + "link": "/api/images/system/map_marker_image_3.png", + "title": "Map marker image 3", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "map_marker_image_3.png", + "publicResourceKey": "KfPfTuvKCeAnmTcKcrvZQHfdU0TPArWY", + "mediaType": "image/png", + "data": "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", + "public": true + } + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/map.json b/application/src/main/data/json/system/widget_types/map.json new file mode 100644 index 0000000000..b9069d8af6 --- /dev/null +++ b/application/src/main/data/json/system/widget_types/map.json @@ -0,0 +1,56 @@ +{ + "fqn": "map", + "name": "Map", + "deprecated": false, + "image": "tb-image;/api/images/system/map-widget.png", + "description": "Displays the location of the entities on Map. Allows to choose among existing tile providers or configure own server. Supports markers, marker tooltips, widget actions, polygons, and circles for enhanced spatial representation.", + "descriptor": { + "type": "latest", + "sizeX": 8.5, + "sizeY": 6, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true,\n additionalWidgetActionTypes: ['placeMapItem']\n };\n}\n", + "settingsForm": [], + "dataKeySettingsForm": [], + "settingsDirective": "tb-map-widget-settings", + "hasBasicMode": true, + "basicModeDirective": "tb-map-basic-config", + "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"mapType\":\"geoMap\",\"markers\":[{\"dsType\":\"function\",\"dsLabel\":\"First point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.8239425680406081,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"trigger\":\"click\",\"autoclose\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See tooltip settings for details\",\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 15.833293;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\"},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || -90.454350;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\"},\"markerType\":\"shape\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"function\",\"color\":\"#307FE5\",\"colorFunction\":\"var temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\\n\"}},\"markerIcon\":{\"icon\":\"mdi:lightbulb-on\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"image\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xOTEuMzUgLTM1MS4xOCAxMDgzLjU4IDE3MzAuNDYiPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjZmU3NTY5IiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMzciIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTM1MS44MzMgMTM2MC43OGMtMzguNzY2LTE5MC4zLTEwNy4xMTYtMzQ4LjY2NS0xODkuOTAzLTQ5NS40NEMxMDAuNTIzIDc1Ni40NjkgMjkuMzg2IDY1NS45NzgtMzYuNDM0IDU1MC40MDRjLTIxLjk3Mi0zNS4yNDQtNDAuOTM0LTcyLjQ3Ny02Mi4wNDctMTA5LjA1NC00Mi4yMTYtNzMuMTM3LTc2LjQ0NC0xNTcuOTM1LTc0LjI2OS0yNjcuOTMyIDIuMTI1LTEwNy40NzMgMzMuMjA4LTE5My42ODUgNzguMDMtMjY0LjE3M0MtMjEtMjA2LjY5IDEwMi40ODEtMzAxLjc0NSAyNjguMTY0LTMyNi43MjRjMTM1LjQ2Ni0yMC40MjUgMjYyLjQ3NSAxNC4wODIgMzUyLjU0MyA2Ni43NDcgNzMuNiA0My4wMzggMTMwLjU5NiAxMDAuNTI4IDE3My45MiAxNjguMjggNDUuMjIgNzAuNzE2IDc2LjM2IDE1NC4yNiA3OC45NzEgMjYzLjIzMyAxLjMzNyA1NS44My03LjgwNSAxMDcuNTMyLTIwLjY4NCAxNTAuNDE3LTEzLjAzNCA0My40MS0zMy45OTYgNzkuNjk1LTUyLjY0NiAxMTguNDU1LTM2LjQwNiA3NS42NTktODIuMDQ5IDE0NC45ODEtMTI3Ljg1NSAyMTQuMzQ1LTEzNi40MzcgMjA2LjYwNi0yNjQuNDk2IDQxNy4zMS0zMjAuNTggNzA2LjAyOHoiLz48Y2lyY2xlIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBjeD0iMzUyLjg5MSIgY3k9IjIyNS43NzkiIHI9IjE4My4zMzIiLz48L3N2Zz4=\",\"imageSize\":34},\"markerOffsetX\":0.5,\"markerOffsetY\":1},{\"dsType\":\"function\",\"dsLabel\":\"Second point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7826299113906372,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"trigger\":\"click\",\"autoclose\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See tooltip settings for details\",\"offsetX\":0,\"offsetY\":-1},\"click\":{\"type\":\"doNothing\"},\"groups\":null,\"edit\":{\"enabledActions\":[],\"snappable\":false},\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 14.450463;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"icon\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerIcon\":{\"size\":40,\"color\":{\"type\":\"function\",\"color\":\"#307FE5\",\"colorFunction\":\"var colors = ['#488bc7','#549c5d','#ed7546','#be2b29'];\\nvar temperature = data.temperature;\\nvar res = colors[0];\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120;\\n var index = Math.min(3, Math.floor(4 * percent));\\n res = colors[index];\\n}\\nreturn res;\"},\"icon\":\"thermostat\"},\"markerImage\":{\"type\":\"function\",\"image\":\"/assets/markers/shape1.svg\",\"imageSize\":34,\"imageFunction\":\"\\n\",\"images\":[]},\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"markerClustering\":{\"enable\":false,\"zoomOnClick\":true,\"maxZoom\":null,\"maxClusterRadius\":80,\"zoomAnimation\":true,\"showCoverageOnHover\":true,\"spiderfyOnMaxZoom\":false,\"chunkedLoad\":false,\"lazyLoad\":true,\"useClusterMarkerColorFunction\":false,\"clusterMarkerColorFunction\":null}}],\"polygons\":[],\"circles\":[],\"additionalDataSources\":[]},\"title\":\"Map\",\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showTitleIcon\":false,\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"widgetCss\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"24px\",\"titleIcon\":\"map\",\"iconColor\":\"#1F6BDD\",\"actions\":{}}" + }, + "resources": [ + { + "link": "/api/images/system/map-widget.png", + "title": "\"Map\" system widget image", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "map-widget.png", + "publicResourceKey": "scAsnySDiQSGXiKpt69cZ9jxZh0zl3eL", + "mediaType": "image/png", + "data": "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", + "public": true + } + ], + "scada": false, + "tags": [ + "markers", + "polygon", + "circle", + "navigation", + "position", + "sensor", + "geolocation", + "satellite", + "roadmap", + "directions", + "placement", + "layer", + "openstreet", + "google", + "tiles", + "location", + "mapping", + "gps" + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/markers_placement___google_maps.json b/application/src/main/data/json/system/widget_types/markers_placement___google_maps.json index e9f492b7fc..bf721b05b7 100644 --- a/application/src/main/data/json/system/widget_types/markers_placement___google_maps.json +++ b/application/src/main/data/json/system/widget_types/markers_placement___google_maps.json @@ -1,7 +1,7 @@ { "fqn": "input_widgets.markers_placement_google_maps", "name": "Markers Placement - Google Maps", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/markers_placement_google_maps_system_widget_image.png", "description": "Allows configuring the location of the selected entities on Google Maps. By default, store the location using 'latitude' and 'longitude' server-side attributes.", "descriptor": { @@ -14,7 +14,7 @@ "controllerScript": "self.onInit = function() {\n self.ctx.map = new TbMapWidgetV2('google-map', false, self.ctx, null, true);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", "settingsSchema": "", "dataKeySettingsSchema": "", - "settingsDirective": "tb-map-widget-settings", + "settingsDirective": "tb-map-widget-settings-legacy", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.05427416942713381,\"funcBody\":\"var value = prevValue || 15.833293;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.680594833308841,\"funcBody\":\"var value = prevValue || -90.454350;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"}]},{\"type\":\"function\",\"name\":\"Second point\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.05012157428742059,\"funcBody\":\"var value = prevValue || 14.450463;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.6742359401617628,\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"fitMapBounds\":true,\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"label\":\"${entityName}\",\"tooltipPattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}

Delete\",\"markerImageSize\":34,\"gmDefaultMapType\":\"roadmap\",\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"useColorFunction\":false,\"markerImages\":[],\"useMarkerImageFunction\":false,\"colorFunction\":\"\\n\",\"color\":\"#fe7569\",\"showTooltip\":true,\"autocloseTooltip\":true,\"defaultCenterPosition\":\"0,0\",\"showTooltipAction\":\"click\",\"polygonKeyName\":\"coordinates\",\"polygonOpacity\":0.5,\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":1,\"zoomOnClick\":true,\"defaultZoomLevel\":5,\"provider\":\"google-map\",\"showCoverageOnHover\":true,\"animate\":true,\"maxClusterRadius\":80,\"removeOutsideVisibleBounds\":true,\"mapProvider\":\"HERE.normalDay\",\"draggableMarker\":true,\"editablePolygon\":true,\"mapPageSize\":16384,\"showPolygon\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${coordinates|ts:7}

Delete\",\"showPolygonTooltip\":false},\"title\":\"Markers Placement - Google Maps\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{\"tooltipAction\":[{\"name\":\"delete\",\"icon\":\"more_horiz\",\"type\":\"custom\",\"customFunction\":\"var entityDatasource = widgetContext.map.map.datasources.filter(\\n function(entity) {\\n return entity.entityId === entityId.id;\\n });\\n\\nwidgetContext.map.setMarkerLocation(entityDatasource[0], null, null).subscribe(() => widgetContext.updateAliases());\",\"id\":\"8d3c0156-0a14-7a6f-0ddd-0ec16b9ffc91\"},{\"name\":\"delete_polygon\",\"icon\":\"more_horiz\",\"type\":\"custom\",\"customFunction\":\"var entityDatasource = widgetContext.map.map.datasources.filter(\\n function(entity) {\\n return entity.entityId === entityId.id\\n });\\n\\nwidgetContext.map.savePolygonLocation(entityDatasource[0], null).subscribe(() => widgetContext.updateAliases());\",\"id\":\"46bf69cd-8906-234c-a879-e2e4c92f5b67\"}]},\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"displayTimewindow\":true}" }, "tags": [ diff --git a/application/src/main/data/json/system/widget_types/markers_placement___image_map.json b/application/src/main/data/json/system/widget_types/markers_placement___image_map.json index 5544471bc3..c4bddab86d 100644 --- a/application/src/main/data/json/system/widget_types/markers_placement___image_map.json +++ b/application/src/main/data/json/system/widget_types/markers_placement___image_map.json @@ -1,7 +1,7 @@ { "fqn": "input_widgets.markers_placement_image_map", "name": "Markers Placement - Image Map", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/markers_placement_image_map_system_widget_image.png", "description": "Allows configuring the location of the selected entities on the Image map. By default, store the location using 'xPos' and 'yPos' server-side attributes with values of 0.0 to 1.0.", "descriptor": { @@ -14,7 +14,7 @@ "controllerScript": "self.onInit = function() {\n self.ctx.map = new TbMapWidgetV2('image-map', false, self.ctx, null, true);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", "settingsSchema": "", "dataKeySettingsSchema": "", - "settingsDirective": "tb-map-widget-settings", + "settingsDirective": "tb-map-widget-settings-legacy", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"xPos\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.05427416942713381,\"funcBody\":\"var value = prevValue || 0.2;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"yPos\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.680594833308841,\"funcBody\":\"var value = prevValue || 0.3;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"}]},{\"type\":\"function\",\"name\":\"Second point\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"xPos\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.05012157428742059,\"funcBody\":\"var value = prevValue || 0.6;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"yPos\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.6742359401617628,\"funcBody\":\"var value = prevValue || 0.7;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showLabel\":true,\"label\":\"${entityName}\",\"tooltipPattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}

Delete\",\"markerImageSize\":34,\"useColorFunction\":false,\"markerImages\":[],\"useMarkerImageFunction\":false,\"color\":\"#fe7569\",\"mapImageUrl\":\"tb-image;/api/images/system/markers_placement_image_map_system_widget_map_image.svg\",\"xPosKeyName\":\"xPos\",\"yPosKeyName\":\"yPos\",\"posFunction\":\"return {x: origXPos, y: origYPos};\",\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"showTooltip\":true,\"autocloseTooltip\":true,\"showTooltipAction\":\"click\",\"defaultCenterPosition\":\"0,0\",\"provider\":\"image-map\",\"fitMapBounds\":true,\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"polygonKeyName\":\"coordinates\",\"polygonOpacity\":0.5,\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":1,\"mapProvider\":\"HERE.normalDay\",\"draggableMarker\":true,\"editablePolygon\":true,\"mapPageSize\":16384,\"showPolygon\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${coordinates|ts:7}

Delete\"},\"title\":\"Markers Placement - Image Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{\"tooltipAction\":[{\"name\":\"delete\",\"icon\":\"more_horiz\",\"type\":\"custom\",\"customFunction\":\"var entityDatasource = widgetContext.map.map.datasources.filter(\\n function(entity) {\\n return entity.entityId === entityId.id;\\n });\\n\\nwidgetContext.map.setMarkerLocation(entityDatasource[0], null, null).subscribe(() => widgetContext.updateAliases());\",\"id\":\"c39f512a-21c6-6b06-3aa1-715262c6553d\"},{\"name\":\"delete_polygon\",\"icon\":\"more_horiz\",\"type\":\"custom\",\"customFunction\":\"var entityDatasource = widgetContext.map.map.datasources.filter(\\n function(entity) {\\n return entity.entityId === entityId.id\\n });\\n\\nwidgetContext.map.savePolygonLocation(entityDatasource[0], null).subscribe(() => widgetContext.updateAliases());\",\"id\":\"94bf5ffd-b526-c6c3-ae3b-ab42191217d9\"}]},\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"displayTimewindow\":true}" }, "tags": [ diff --git a/application/src/main/data/json/system/widget_types/markers_placement___openstreetmap.json b/application/src/main/data/json/system/widget_types/markers_placement___openstreetmap.json index a351a75564..da96abb49e 100644 --- a/application/src/main/data/json/system/widget_types/markers_placement___openstreetmap.json +++ b/application/src/main/data/json/system/widget_types/markers_placement___openstreetmap.json @@ -1,7 +1,7 @@ { "fqn": "input_widgets.markers_placement_openstreetmap", "name": "Markers Placement - OpenStreetMap", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/markers_placement_openstreetmap_system_widget_image.png", "description": "Allows configuring the location of the selected entities on OpenStreetMap. By default, store the location using 'latitude' and 'longitude' server-side attributes.", "descriptor": { @@ -14,7 +14,7 @@ "controllerScript": "self.onInit = function() {\n self.ctx.map = new TbMapWidgetV2('openstreet-map', false, self.ctx, null, true);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", "settingsSchema": "", "dataKeySettingsSchema": "", - "settingsDirective": "tb-map-widget-settings", + "settingsDirective": "tb-map-widget-settings-legacy", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.05427416942713381,\"funcBody\":\"var value = prevValue || 15.833293;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.680594833308841,\"funcBody\":\"var value = prevValue || -90.454350;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"}]},{\"type\":\"function\",\"name\":\"Second point\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#607d8b\",\"settings\":{},\"_hash\":0.7867521952070078,\"funcBody\":\"var value = prevValue || 14.450463;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.7040053227577256,\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"fitMapBounds\":true,\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"label\":\"${entityName}\",\"tooltipPattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}

Delete\",\"markerImageSize\":34,\"useColorFunction\":false,\"markerImages\":[],\"useMarkerImageFunction\":false,\"color\":\"#fe7569\",\"mapProvider\":\"OpenStreetMap.Mapnik\",\"showTooltip\":true,\"autocloseTooltip\":true,\"defaultCenterPosition\":\"0,0\",\"customProviderTileUrl\":\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\",\"showTooltipAction\":\"click\",\"polygonKeyName\":\"coordinates\",\"polygonOpacity\":0.5,\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":1,\"zoomOnClick\":true,\"showCoverageOnHover\":true,\"animate\":true,\"maxClusterRadius\":80,\"removeOutsideVisibleBounds\":true,\"defaultZoomLevel\":5,\"provider\":\"openstreet-map\",\"draggableMarker\":true,\"editablePolygon\":true,\"mapPageSize\":16384,\"showPolygon\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${coordinates|ts:7}

Delete\"},\"title\":\"Markers Placement - OpenStreetMap\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{\"tooltipAction\":[{\"name\":\"delete\",\"icon\":\"more_horiz\",\"type\":\"custom\",\"customFunction\":\"var entityDatasource = widgetContext.map.map.datasources.filter(\\n function(entity) {\\n return entity.entityId === entityId.id;\\n });\\n\\nwidgetContext.map.setMarkerLocation(entityDatasource[0], null, null).subscribe(() => widgetContext.updateAliases());\",\"id\":\"54c293c4-9ca6-e34f-dc6a-0271944c1c66\"},{\"name\":\"delete_polygon\",\"icon\":\"more_horiz\",\"type\":\"custom\",\"customFunction\":\"var entityDatasource = widgetContext.map.map.datasources.filter(\\n function(entity) {\\n return entity.entityId === entityId.id\\n });\\n\\nwidgetContext.map.savePolygonLocation(entityDatasource[0], null).subscribe(() => widgetContext.updateAliases());\",\"id\":\"6beb7bed-dfd8-388d-b60c-82988ab52f06\"}]},\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"displayTimewindow\":true}" }, "tags": [ diff --git a/application/src/main/data/json/system/widget_types/openstreet_map.json b/application/src/main/data/json/system/widget_types/openstreet_map.json index 15454743f3..b0294e0988 100644 --- a/application/src/main/data/json/system/widget_types/openstreet_map.json +++ b/application/src/main/data/json/system/widget_types/openstreet_map.json @@ -1,7 +1,7 @@ { "fqn": "maps_v2.openstreetmap", "name": "OpenStreet Map", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/openstreet_map_system_widget_image.png", "description": "Displays the location of the entities on OpenStreetMap. Allows to choose among existing tile providers or configure own server. Some providers require additional licenses. Highly customizable via custom markers, marker tooltips, and widget actions. ", "descriptor": { @@ -14,7 +14,7 @@ "controllerScript": "self.onInit = function() {\n self.ctx.map = new TbMapWidgetV2('openstreet-map', false, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", "settingsSchema": "", "dataKeySettingsSchema": "", - "settingsDirective": "tb-map-widget-settings", + "settingsDirective": "tb-map-widget-settings-legacy", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.05427416942713381,\"funcBody\":\"var value = prevValue || 15.833293;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.680594833308841,\"funcBody\":\"var value = prevValue || -90.454350;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.9430343126300238,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.1784452363910778,\"funcBody\":\"return \\\"colorpin\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]},{\"type\":\"function\",\"name\":\"Second point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.05012157428742059,\"funcBody\":\"var value = prevValue || 14.450463;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.6742359401617628,\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.773875863339494,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#3f51b5\",\"settings\":{},\"_hash\":0.405822538899673,\"funcBody\":\"return \\\"thermometer\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"provider\":\"openstreet-map\",\"mapProvider\":\"OpenStreetMap.Mapnik\",\"useCustomProvider\":false,\"customProviderTileUrl\":\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"xPosKeyName\":\"xPos\",\"yPosKeyName\":\"yPos\",\"defaultCenterPosition\":\"0,0\",\"disableScrollZooming\":false,\"disableDoubleClickZooming\":false,\"disableZoomControl\":false,\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"mapPageSize\":16384,\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"posFunction\":\"return {x: origXPos, y: origYPos};\",\"draggableMarker\":false,\"showLabel\":true,\"useLabelFunction\":false,\"label\":\"${entityName}\",\"showTooltip\":true,\"showTooltipAction\":\"click\",\"autocloseTooltip\":true,\"useTooltipFunction\":false,\"tooltipPattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See advanced settings for details\",\"tooltipOffsetX\":0,\"tooltipOffsetY\":-1,\"color\":\"#fe7569\",\"useColorFunction\":true,\"colorFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'colorpin') {\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120 * 100;\\n\\t return tinycolor.mix('blue', 'red', percent).toHexString();\\n\\t}\\n\\treturn 'blue';\\n}\\n\",\"useMarkerImageFunction\":true,\"markerImageSize\":34,\"markerImageFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'thermometer') {\\n\\tvar res = {\\n\\t url: images[0],\\n\\t size: 40\\n\\t}\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120;\\n\\t var index = Math.min(3, Math.floor(4 * percent));\\n\\t res.url = images[index];\\n\\t}\\n\\treturn res;\\n}\",\"markerImages\":[\"tb-image;/api/images/system/map_marker_image_0.png\",\"tb-image;/api/images/system/map_marker_image_1.png\",\"tb-image;/api/images/system/map_marker_image_2.png\",\"tb-image;/api/images/system/map_marker_image_3.png\"],\"showPolygon\":false,\"polygonKeyName\":\"perimeter\",\"editablePolygon\":false,\"showPolygonLabel\":false,\"usePolygonLabelFunction\":false,\"polygonLabel\":\"${entityName}\",\"showPolygonTooltip\":false,\"showPolygonTooltipAction\":\"click\",\"autoClosePolygonTooltip\":true,\"usePolygonTooltipFunction\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"polygonColor\":\"#3388ff\",\"polygonOpacity\":0.2,\"usePolygonColorFunction\":false,\"polygonStrokeColor\":\"#3388ff\",\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":3,\"usePolygonStrokeColorFunction\":false,\"showCircle\":false,\"circleKeyName\":\"perimeter\",\"editableCircle\":false,\"showCircleLabel\":false,\"useCircleLabelFunction\":false,\"circleLabel\":\"${entityName}\",\"showCircleTooltip\":false,\"showCircleTooltipAction\":\"click\",\"autoCloseCircleTooltip\":true,\"useCircleTooltipFunction\":false,\"circleTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"circleFillColor\":\"#3388ff\",\"circleFillColorOpacity\":0.2,\"useCircleFillColorFunction\":false,\"circleStrokeColor\":\"#3388ff\",\"circleStrokeOpacity\":1,\"circleStrokeWeight\":3,\"useCircleStrokeColorFunction\":false,\"useClusterMarkers\":false,\"zoomOnClick\":true,\"maxClusterRadius\":80,\"animate\":true,\"spiderfyOnMaxZoom\":false,\"showCoverageOnHover\":true,\"chunkedLoading\":false,\"removeOutsideVisibleBounds\":true,\"useIconCreateFunction\":false},\"title\":\"OpenStreet Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}" }, "tags": [ diff --git a/application/src/main/data/json/system/widget_types/route_map.json b/application/src/main/data/json/system/widget_types/route_map.json new file mode 100644 index 0000000000..f1d2cd571c --- /dev/null +++ b/application/src/main/data/json/system/widget_types/route_map.json @@ -0,0 +1,96 @@ +{ + "fqn": "route_map", + "name": "Route Map", + "deprecated": false, + "image": "tb-image;/api/images/system/route-map-widget.png", + "description": "Displays an entity's trip on various map providers. Supports custom markers, marker tooltips, widget actions, polygons, and circles for enhanced spatial representation.", + "descriptor": { + "type": "timeseries", + "sizeX": 8.5, + "sizeY": 6, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n trip: true,\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true,\n additionalWidgetActionTypes: ['placeMapItem']\n };\n}", + "settingsForm": [], + "dataKeySettingsForm": [], + "latestDataKeySettingsForm": [], + "settingsDirective": "tb-map-widget-settings", + "dataKeySettingsDirective": "", + "latestDataKeySettingsDirective": "", + "hasBasicMode": true, + "basicModeDirective": "tb-map-basic-config", + "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"history\":{\"interval\":1000,\"timewindowMs\":60000},\"aggregation\":{\"type\":\"NONE\",\"limit\":500}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"mapType\":\"geoMap\",\"markers\":[],\"polygons\":[],\"circles\":[],\"additionalDataSources\":[],\"trips\":[{\"dsType\":\"function\",\"dsLabel\":\"First route\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Speed\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.17490048149347315,\"funcBody\":\"var value = prevValue;\\nif (!value) {\\n value = 45;\\n}\\nif (time % 500 < 500) {\\n value = value + Math.random() * 40 - 20;\\n if (value < 45) {\\n \\tvalue = 45;\\n } else if (value > 130) {\\n \\tvalue = 130;\\n }\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"trigger\":\"click\",\"autoclose\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Speed: ${Speed} MPH\",\"offsetX\":0,\"offsetY\":-1,\"patternFunction\":null,\"tagActions\":null},\"click\":{\"type\":\"doNothing\"},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var lats = [37.7696499,\\n37.7699074,\\n37.7699536,\\n37.7697242,\\n37.7695189,\\n37.7696889,\\n37.7697153,\\n37.7701244,\\n37.7700604,\\n37.7705491,\\n37.7715705,\\n37.771752,\\n37.7707533,\\n37.769866];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lats[i];\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var lons = [-122.4261215,\\n-122.4219157,\\n-122.4199623,\\n-122.4179074,\\n-122.4155876,\\n-122.4155521,\\n-122.4163203,\\n-122.4193876,\\n-122.4210496,\\n-122.422284,\\n-122.4232717,\\n-122.4235138,\\n-122.4247605,\\n-122.4258812];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lons[i];\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"image\",\"markerShape\":{\"shape\":\"tripMarkerShape1\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerIcon\":{\"icon\":\"arrow_forward\",\"size\":48,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"function\",\"image\":\"/assets/markers/tripShape1.svg\",\"imageSize\":34,\"imageFunction\":\"var speed = data.Speed;\\nvar res = {\\n url: images[0],\\n size: 55\\n};\\nif (typeof speed !== undefined) {\\n var percent = (speed - 45)/85;\\n var index = Math.min(2, Math.floor(3 * percent));\\n res.url = images[index];\\n}\\nreturn res;\",\"images\":[\"tb-image;/api/images/system/map_marker_image_0.png\",\"tb-image;/api/images/system/map_marker_image_1.png\",\"tb-image;/api/images/system/map_marker_image_2.png\"]},\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"rotateMarker\":true,\"offsetAngle\":0,\"showPath\":true,\"pathStrokeWeight\":4,\"pathStrokeColor\":{\"type\":\"function\",\"color\":\"#307FE5\",\"colorFunction\":\"var speed = data.Speed;\\nif (typeof speed !== undefined) {\\n var percent = (speed - 45)/85;\\n if (percent < 0.5) {\\n percent *=2*100; \\n return tinycolor.mix('green', 'yellow', percent).setAlpha(0.65).toRgbString();\\n } else {\\n percent = (percent - 0.5)*2*100;\\n return tinycolor.mix('yellow', 'red', percent).setAlpha(0.65).toRgbString();\\n }\\n}\"},\"usePathDecorator\":false,\"pathDecoratorSymbol\":\"arrowHead\",\"pathDecoratorSymbolSize\":10,\"pathDecoratorSymbolColor\":\"#307FE5\",\"pathDecoratorOffset\":20,\"pathEndDecoratorOffset\":20,\"pathDecoratorRepeat\":20,\"showPoints\":false,\"pointSize\":10,\"pointColor\":{\"type\":\"constant\",\"color\":\"#307FE5\"},\"pointTooltip\":{\"show\":true,\"trigger\":\"click\",\"autoclose\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
End Time: ${maxTime}
Start Time: ${minTime}\",\"offsetX\":0,\"offsetY\":-1}}],\"tripTimeline\":{\"showTimelineControl\":false}},\"title\":\"Route Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":null,\"mobileHeight\":null,\"configMode\":\"basic\",\"actions\":{},\"showTitleIcon\":false,\"titleIcon\":\"assistant_navigation\",\"iconColor\":\"#1F6BDD\",\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"titleFont\":{\"size\":null,\"sizeUnit\":\"px\",\"family\":null,\"weight\":null,\"style\":null,\"lineHeight\":null},\"titleColor\":null,\"titleTooltip\":\"\",\"widgetStyle\":{},\"widgetCss\":\"\",\"pageSize\":1024,\"units\":\"\",\"decimals\":null,\"noDataDisplayMessage\":\"\",\"timewindowStyle\":{\"showIcon\":false,\"iconSize\":\"24px\",\"icon\":null,\"iconPosition\":\"left\",\"font\":{\"size\":12,\"sizeUnit\":\"px\",\"family\":\"Roboto\",\"weight\":\"400\",\"style\":\"normal\",\"lineHeight\":\"16px\"},\"color\":\"rgba(0, 0, 0, 0.38)\",\"displayTypePrefix\":true},\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"24px\"}" + }, + "resources": [ + { + "link": "/api/images/system/map_marker_image_0.png", + "title": "Map marker image 0", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "map_marker_image_0.png", + "publicResourceKey": "LPbcriZ2v053mkWb33T5JdK7Agkt1jGg", + "mediaType": "image/png", + "data": "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", + "public": true + }, + { + "link": "/api/images/system/map_marker_image_1.png", + "title": "Map marker image 1", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "map_marker_image_1.png", + "publicResourceKey": "TwKYnwJfaCIgDbJsetgcj3q7AYK4HUSA", + "mediaType": "image/png", + "data": "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", + "public": true + }, + { + "link": "/api/images/system/map_marker_image_2.png", + "title": "Map marker image 2", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "map_marker_image_2.png", + "publicResourceKey": "FazBQsEp1uSeIsT1XL31o2npLAx5s3zJ", + "mediaType": "image/png", + "data": "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", + "public": true + }, + { + "link": "/api/images/system/route-map-widget.png", + "title": "\"Route Map\" system widget image", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "route-map-widget.png", + "publicResourceKey": "xHDxUSAefNkVjlpwj2OoNCHgKGGJLfbx", + "mediaType": "image/png", + "data": "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", + "public": true + } + ], + "scada": false, + "tags": [ + "trip", + "route", + "movement", + "tracking", + "path", + "marker", + "location", + "point", + "satellite", + "directions", + "placement", + "polygon", + "circle", + "layer", + "openstreet", + "google", + "tiles", + "roadmap", + "mapping", + "gps", + "navigation", + "geolocation" + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/route_map___google.json b/application/src/main/data/json/system/widget_types/route_map___google.json index ac7f511687..6eda23ef00 100644 --- a/application/src/main/data/json/system/widget_types/route_map___google.json +++ b/application/src/main/data/json/system/widget_types/route_map___google.json @@ -1,7 +1,7 @@ { "fqn": "maps_v2.route_map", "name": "Route Map - Google", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/route_map_google_system_widget_image.png", "description": "Visualize the entity trip on Google Maps. Allows to visualize location history. Use the Trip Animation widget for advanced features.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/route_map___openstreet.json b/application/src/main/data/json/system/widget_types/route_map___openstreet.json index 9021fac8aa..92bb2516a9 100644 --- a/application/src/main/data/json/system/widget_types/route_map___openstreet.json +++ b/application/src/main/data/json/system/widget_types/route_map___openstreet.json @@ -1,7 +1,7 @@ { "fqn": "maps_v2.route_map_openstreetmap", "name": "Route Map - OpenStreet", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/route_map_openstreet_system_widget_image.png", "description": "Visualize the entity trip on OpenStreetMap. Allows to visualize location history. Use the Trip Animation widget for advanced features.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/route_map___tencent.json b/application/src/main/data/json/system/widget_types/route_map___tencent.json index f2f01a5b57..3de304c8fc 100644 --- a/application/src/main/data/json/system/widget_types/route_map___tencent.json +++ b/application/src/main/data/json/system/widget_types/route_map___tencent.json @@ -1,7 +1,7 @@ { "fqn": "maps_v2.route_map_tencent_maps", "name": "Route Map - Tencent", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/route_map_tencent_system_widget_image.png", "description": "Visualize the entity trip on Tencent Maps. Allows to visualize location history. Use the Trip Animation widget for advanced features.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/tencent_map.json b/application/src/main/data/json/system/widget_types/tencent_map.json index a3e480f836..ec41717ca4 100644 --- a/application/src/main/data/json/system/widget_types/tencent_map.json +++ b/application/src/main/data/json/system/widget_types/tencent_map.json @@ -1,7 +1,7 @@ { "fqn": "maps_v2.tencent_maps", "name": "Tencent Map", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/tencent_map_system_widget_image.png", "description": "Displays the location of the entities on Tencent maps. Requires the Tencent map key to work properly. Highly customizable via custom markers, marker tooltips, and widget actions.", "descriptor": { @@ -14,7 +14,7 @@ "controllerScript": "self.onInit = function() {\n self.ctx.map = new TbMapWidgetV2('tencent-map', false, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", "settingsSchema": "", "dataKeySettingsSchema": "", - "settingsDirective": "tb-map-widget-settings", + "settingsDirective": "tb-map-widget-settings-legacy", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue || 15.833293;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.24727730589425012,\"funcBody\":\"var value = prevValue || -90.454350;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.8437014651129422,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.7558240907832925,\"funcBody\":\"return \\\"colorpin\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]},{\"type\":\"function\",\"name\":\"Second Point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#607d8b\",\"settings\":{},\"_hash\":0.19266205227372524,\"funcBody\":\"var value = prevValue || 14.450463;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.7995830793603149,\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.04902495467943502,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#3f51b5\",\"settings\":{},\"_hash\":0.44120841439482095,\"funcBody\":\"return \\\"thermometer\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"provider\":\"tencent-map\",\"tmApiKey\":\"84d6d83e0e51e481e50454ccbe8986b\",\"tmDefaultMapType\":\"roadmap\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"xPosKeyName\":\"xPos\",\"yPosKeyName\":\"yPos\",\"defaultCenterPosition\":\"0,0\",\"disableScrollZooming\":false,\"disableDoubleClickZooming\":false,\"disableZoomControl\":false,\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"mapPageSize\":16384,\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"posFunction\":\"return {x: origXPos, y: origYPos};\",\"draggableMarker\":false,\"showLabel\":true,\"useLabelFunction\":false,\"label\":\"${entityName}\",\"showTooltip\":true,\"showTooltipAction\":\"click\",\"autocloseTooltip\":true,\"useTooltipFunction\":false,\"tooltipPattern\":\"
${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See advanced settings for details
\",\"tooltipOffsetX\":0,\"tooltipOffsetY\":-1,\"color\":\"#fe7569\",\"useColorFunction\":true,\"colorFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'colorpin') {\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120 * 100;\\n\\t return tinycolor.mix('blue', 'red', percent).toHexString();\\n\\t}\\n\\treturn 'blue';\\n}\\n\",\"useMarkerImageFunction\":true,\"markerImageSize\":34,\"markerImageFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'thermometer') {\\n\\tvar res = {\\n\\t url: images[0],\\n\\t size: 40\\n\\t}\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120;\\n\\t var index = Math.min(3, Math.floor(4 * percent));\\n\\t res.url = images[index];\\n\\t}\\n\\treturn res;\\n}\",\"markerImages\":[\"tb-image;/api/images/system/map_marker_image_0.png\",\"tb-image;/api/images/system/map_marker_image_1.png\",\"tb-image;/api/images/system/map_marker_image_2.png\",\"tb-image;/api/images/system/map_marker_image_3.png\"],\"showPolygon\":false,\"polygonKeyName\":\"coordinates\",\"editablePolygon\":false,\"showPolygonLabel\":false,\"usePolygonLabelFunction\":false,\"polygonLabel\":\"${entityName}\",\"showPolygonTooltip\":false,\"showPolygonTooltipAction\":\"click\",\"autoClosePolygonTooltip\":true,\"usePolygonTooltipFunction\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"polygonColor\":\"#3388ff\",\"polygonOpacity\":0.5,\"usePolygonColorFunction\":false,\"polygonStrokeColor\":\"#3388ff\",\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":1,\"usePolygonStrokeColorFunction\":false,\"showCircle\":false,\"circleKeyName\":\"perimeter\",\"editableCircle\":false,\"showCircleLabel\":false,\"useCircleLabelFunction\":false,\"circleLabel\":\"${entityName}\",\"showCircleTooltip\":false,\"showCircleTooltipAction\":\"click\",\"autoCloseCircleTooltip\":true,\"useCircleTooltipFunction\":false,\"circleTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"circleFillColor\":\"#3388ff\",\"circleFillColorOpacity\":0.2,\"useCircleFillColorFunction\":false,\"circleStrokeColor\":\"#3388ff\",\"circleStrokeOpacity\":1,\"circleStrokeWeight\":3,\"useCircleStrokeColorFunction\":false,\"useClusterMarkers\":false,\"zoomOnClick\":true,\"maxClusterRadius\":80,\"animate\":true,\"spiderfyOnMaxZoom\":false,\"showCoverageOnHover\":true,\"chunkedLoading\":false,\"removeOutsideVisibleBounds\":true,\"useIconCreateFunction\":false},\"title\":\"Tencent Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" }, "tags": [ diff --git a/application/src/main/data/json/system/widget_types/trip_animation.json b/application/src/main/data/json/system/widget_types/trip_animation.json index e4429dd0b5..52eb7b8503 100644 --- a/application/src/main/data/json/system/widget_types/trip_animation.json +++ b/application/src/main/data/json/system/widget_types/trip_animation.json @@ -1,7 +1,7 @@ { "fqn": "maps_v2.test", "name": "Trip Animation", - "deprecated": false, + "deprecated": true, "image": "tb-image;/api/images/system/trip_animation_system_widget_image.png", "description": "Displays the trip of the entity on the OpenStreetMap or other map providers. Allows to scroll and animate the movement of the entity. Highly customizable via custom markers, marker tooltips, and widget actions.", "descriptor": { diff --git a/application/src/main/data/json/system/widget_types/trip_map.json b/application/src/main/data/json/system/widget_types/trip_map.json new file mode 100644 index 0000000000..a596e74c1f --- /dev/null +++ b/application/src/main/data/json/system/widget_types/trip_map.json @@ -0,0 +1,64 @@ +{ + "fqn": "trip_map", + "name": "Trip Map", + "deprecated": false, + "image": "tb-image;/api/images/system/trip-map-widget.png", + "description": "Displays an entity's trip on various map providers, allowing scrolling and animated movement. Supports custom markers, marker tooltips, widget actions, polygons, and circles for enhanced spatial representation.", + "descriptor": { + "type": "timeseries", + "sizeX": 8.5, + "sizeY": 6, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n trip: true,\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true,\n additionalWidgetActionTypes: ['placeMapItem']\n };\n}", + "settingsForm": [], + "dataKeySettingsForm": [], + "latestDataKeySettingsForm": [], + "settingsDirective": "tb-map-widget-settings", + "dataKeySettingsDirective": "", + "latestDataKeySettingsDirective": "", + "hasBasicMode": true, + "basicModeDirective": "tb-map-basic-config", + "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"history\":{\"interval\":1000,\"timewindowMs\":60000},\"aggregation\":{\"type\":\"NONE\",\"limit\":500}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"mapType\":\"geoMap\",\"markers\":[],\"polygons\":[],\"circles\":[],\"additionalDataSources\":[],\"trips\":[{\"dsType\":\"function\",\"dsLabel\":\"First point\",\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var gpsData = [\\n37.771210000, -122.510960000,\\n 37.771990000, -122.497070000,\\n 37.772730000, -122.480740000,\\n 37.773360000, -122.466870000,\\n 37.774270000, -122.458520000,\\n 37.771980000, -122.454110000,\\n 37.768250000, -122.453380000,\\n 37.765920000, -122.456810000,\\n 37.765930000, -122.467680000,\\n 37.765500000, -122.477180000,\\n 37.765300000, -122.481660000,\\n 37.764780000, -122.493350000,\\n 37.764120000, -122.508360000,\\n 37.766410000, -122.510260000,\\n 37.770010000, -122.510830000,\\n 37.770980000, -122.510930000\\n];\\n let value = gpsData.indexOf(prevValue); \\nreturn gpsData[(value == -1 ? 0 : (value + 2) % gpsData.length)];\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var gpsData = [\\n37.771210000, -122.510960000,\\n 37.771990000, -122.497070000,\\n 37.772730000, -122.480740000,\\n 37.773360000, -122.466870000,\\n 37.774270000, -122.458520000,\\n 37.771980000, -122.454110000,\\n 37.768250000, -122.453380000,\\n 37.765920000, -122.456810000,\\n 37.765930000, -122.467680000,\\n 37.765500000, -122.477180000,\\n 37.765300000, -122.481660000,\\n 37.764780000, -122.493350000,\\n 37.764120000, -122.508360000,\\n 37.766410000, -122.510260000,\\n 37.770010000, -122.510830000,\\n 37.770980000, -122.510930000\\n];\\n let value = gpsData.indexOf(prevValue); \\nreturn gpsData[(value == -1 ? 1 : (value + 2) % gpsData.length)];\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"shape\",\"markerShape\":{\"shape\":\"tripMarkerShape1\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerIcon\":{\"icon\":\"arrow_forward\",\"size\":48,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"image\",\"image\":\"/assets/markers/tripShape1.svg\",\"imageSize\":34},\"markerOffsetX\":0.5,\"markerOffsetY\":0.5,\"positionFunction\":\"return {x: origXPos, y: origYPos};\",\"markerClustering\":{\"enable\":false,\"zoomOnClick\":true,\"maxZoom\":null,\"maxClusterRadius\":80,\"zoomAnimation\":true,\"showCoverageOnHover\":true,\"spiderfyOnMaxZoom\":false,\"chunkedLoad\":false,\"lazyLoad\":true,\"useClusterMarkerColorFunction\":false,\"clusterMarkerColorFunction\":null},\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"trigger\":\"click\",\"autoclose\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
End Time: ${maxTime}
Start Time: ${minTime}\",\"offsetX\":0,\"offsetY\":-0.5},\"click\":{\"type\":\"doNothing\"},\"edit\":{\"enabledActions\":[],\"snappable\":false},\"rotateMarker\":true,\"offsetAngle\":0,\"showPath\":true,\"pathStrokeWeight\":2,\"pathStrokeColor\":{\"type\":\"constant\",\"color\":\"#307FE5\"},\"usePathDecorator\":false,\"pathDecoratorSymbol\":\"arrowHead\",\"pathDecoratorSymbolSize\":10,\"pathDecoratorSymbolColor\":\"#307FE5\",\"pathDecoratorOffset\":20,\"pathEndDecoratorOffset\":20,\"pathDecoratorRepeat\":20,\"showPoints\":false,\"pointSize\":10,\"pointColor\":{\"type\":\"constant\",\"color\":\"#307FE5\"},\"pointTooltip\":{\"show\":true,\"trigger\":\"click\",\"autoclose\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
End Time: ${maxTime}
Start Time: ${minTime}\",\"offsetX\":0,\"offsetY\":-1}}],\"tripTimeline\":{\"showTimelineControl\":true}},\"title\":\"Trip Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":null,\"mobileHeight\":null,\"configMode\":\"basic\",\"actions\":{},\"showTitleIcon\":false,\"titleIcon\":\"assistant_navigation\",\"iconColor\":\"#1F6BDD\",\"useDashboardTimewindow\":false,\"displayTimewindow\":true,\"titleFont\":{\"size\":null,\"sizeUnit\":\"px\",\"family\":null,\"weight\":null,\"style\":null,\"lineHeight\":null},\"titleColor\":null,\"titleTooltip\":\"\",\"widgetStyle\":{},\"widgetCss\":\"\",\"pageSize\":1024,\"units\":\"\",\"decimals\":null,\"noDataDisplayMessage\":\"\",\"timewindowStyle\":{\"showIcon\":false,\"iconSize\":\"24px\",\"icon\":null,\"iconPosition\":\"left\",\"font\":{\"size\":12,\"sizeUnit\":\"px\",\"family\":\"Roboto\",\"weight\":\"400\",\"style\":\"normal\",\"lineHeight\":\"16px\"},\"color\":\"rgba(0, 0, 0, 0.38)\",\"displayTypePrefix\":true},\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"24px\"}" + }, + "resources": [ + { + "link": "/api/images/system/trip-map-widget.png", + "title": "\"Trip Map\" system widget image", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "trip-map-widget.png", + "publicResourceKey": "nF3ox4p08cuECHLQYNdnhFUpkjK9Uw7P", + "mediaType": "image/png", + "data": "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", + "public": true + } + ], + "scada": false, + "tags": [ + "trip", + "route", + "movement", + "tracking", + "path", + "point", + "timeline", + "marker", + "location", + "satellite", + "directions", + "placement", + "polygon", + "circle", + "layer", + "openstreet", + "google", + "tiles", + "roadmap", + "mapping", + "gps", + "navigation", + "geolocation" + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/tenant/device_profile/rule_chain_template.json b/application/src/main/data/json/tenant/device_profile/rule_chain_template.json index 0f2473cde6..305dc04961 100644 --- a/application/src/main/data/json/tenant/device_profile/rule_chain_template.json +++ b/application/src/main/data/json/tenant/device_profile/rule_chain_template.json @@ -35,8 +35,11 @@ }, "type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode", "name": "Save Client Attributes", - "configurationVersion": 2, + "configurationVersion": 3, "configuration": { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, "scope": "CLIENT_SCOPE", "notifyDevice": false, "sendAttributesUpdatedNotification": false, diff --git a/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json b/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json index 8efda98c5b..a988c9d5eb 100644 --- a/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json +++ b/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json @@ -34,8 +34,11 @@ }, "type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode", "name": "Save Client Attributes", - "configurationVersion": 2, + "configurationVersion": 3, "configuration": { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, "scope": "CLIENT_SCOPE", "notifyDevice": false, "sendAttributesUpdatedNotification": false, diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 2929635949..29c7a084f4 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -16,50 +16,70 @@ -- UPDATE SAVE TIME SERIES NODES START -DO $$ - BEGIN - -- Check if the rule_node table exists - IF EXISTS ( - SELECT 1 - FROM information_schema.tables - WHERE table_name = 'rule_node' - ) THEN +UPDATE rule_node +SET configuration = ( + (configuration::jsonb - 'skipLatestPersistence') + || jsonb_build_object( + 'processingSettings', jsonb_build_object( + 'type', 'ADVANCED', + 'timeseries', jsonb_build_object('type', 'ON_EVERY_MESSAGE'), + 'latest', jsonb_build_object('type', 'SKIP'), + 'webSockets', jsonb_build_object('type', 'ON_EVERY_MESSAGE'), + 'calculatedFields', jsonb_build_object('type', 'ON_EVERY_MESSAGE') + ) + ) + )::text, + configuration_version = 1 +WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode' + AND configuration_version = 0 + AND configuration::jsonb ->> 'skipLatestPersistence' = 'true'; - UPDATE rule_node - SET configuration = ( - (configuration::jsonb - 'skipLatestPersistence') - || jsonb_build_object( - 'processingSettings', jsonb_build_object( - 'type', 'ADVANCED', - 'timeseries', jsonb_build_object('type', 'ON_EVERY_MESSAGE'), - 'latest', jsonb_build_object('type', 'SKIP'), - 'webSockets', jsonb_build_object('type', 'ON_EVERY_MESSAGE') - ) - ) - )::text, - configuration_version = 1 - WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode' - AND configuration_version = 0 - AND configuration::jsonb ->> 'skipLatestPersistence' = 'true'; +UPDATE rule_node +SET configuration = ( + (configuration::jsonb - 'skipLatestPersistence') + || jsonb_build_object( + 'processingSettings', jsonb_build_object( + 'type', 'ON_EVERY_MESSAGE' + ) + ) + )::text, + configuration_version = 1 +WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode' + AND configuration_version = 0 + AND (configuration::jsonb ->> 'skipLatestPersistence' != 'true' OR configuration::jsonb ->> 'skipLatestPersistence' IS NULL); - UPDATE rule_node - SET configuration = ( - (configuration::jsonb - 'skipLatestPersistence') - || jsonb_build_object( - 'processingSettings', jsonb_build_object( - 'type', 'ON_EVERY_MESSAGE' - ) - ) - )::text, - configuration_version = 1 - WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode' - AND configuration_version = 0 - AND (configuration::jsonb ->> 'skipLatestPersistence' != 'true' OR configuration::jsonb ->> 'skipLatestPersistence' IS NULL); +-- UPDATE SAVE TIME SERIES NODES END - END IF; - END; -$$; +-- UPDATE SAVE ATTRIBUTES NODES START --- UPDATE SAVE TIME SERIES NODES END +UPDATE rule_node +SET configuration = ( + configuration::jsonb + || jsonb_build_object( + 'processingSettings', jsonb_build_object('type', 'ON_EVERY_MESSAGE') + ) + )::text, + configuration_version = 3 +WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode' + AND configuration_version = 2; + +-- UPDATE SAVE ATTRIBUTES NODES END + +ALTER TABLE api_usage_state ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; + +-- UPDATE TENANT PROFILE CALCULATED FIELD LIMITS START + +UPDATE tenant_profile +SET profile_data = profile_data + || jsonb_build_object( + 'configuration', profile_data->'configuration' || jsonb_build_object( + 'maxCalculatedFieldsPerEntity', COALESCE(profile_data->'configuration'->>'maxCalculatedFieldsPerEntity', '5')::bigint, + 'maxArgumentsPerCF', COALESCE(profile_data->'configuration'->>'maxArgumentsPerCF', '10')::bigint, + 'maxDataPointsPerRollingArg', COALESCE(profile_data->'configuration'->>'maxDataPointsPerRollingArg', '1000')::bigint, + 'maxStateSizeInKBytes', COALESCE(profile_data->'configuration'->>'maxStateSizeInKBytes', '32')::bigint, + 'maxSingleValueArgumentSizeInKBytes', COALESCE(profile_data->'configuration'->>'maxSingleValueArgumentSizeInKBytes', '2')::bigint + ) + ) +WHERE profile_data->'configuration'->>'maxCalculatedFieldsPerEntity' IS NULL; -ALTER TABLE api_usage_state ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; \ No newline at end of file +-- UPDATE TENANT PROFILE CALCULATED FIELD LIMITS END diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index eb76473386..1ed919e922 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -28,12 +28,11 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.NotificationCenter; -import org.thingsboard.rule.engine.api.RuleEngineDeviceStateManager; +import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.rule.engine.api.notification.SlackService; import org.thingsboard.rule.engine.api.sms.SmsSenderFactory; @@ -41,13 +40,18 @@ import org.thingsboard.script.api.js.JsInvokeService; import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.actors.service.ActorService; import org.thingsboard.server.actors.tenant.DebugTbRateLimits; +import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.event.CalculatedFieldDebugEvent; import org.thingsboard.server.common.data.event.ErrorEvent; import org.thingsboard.server.common.data.event.LifecycleEvent; import org.thingsboard.server.common.data.event.RuleChainDebugEvent; import org.thingsboard.server.common.data.event.RuleNodeDebugEvent; +import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.limit.LimitedApi; +import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.msg.TbActorMsg; import org.thingsboard.server.common.msg.TbMsg; @@ -62,6 +66,7 @@ import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.cassandra.CassandraCluster; +import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.ClaimDevicesService; @@ -94,6 +99,7 @@ import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.tenant.TenantProfileService; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.dao.usagerecord.ApiLimitService; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetsBundleService; @@ -101,6 +107,11 @@ import org.thingsboard.server.queue.discovery.DiscoveryService; import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.service.cf.CalculatedFieldProcessingService; +import org.thingsboard.server.service.cf.CalculatedFieldQueueService; +import org.thingsboard.server.service.cf.CalculatedFieldStateService; +import org.thingsboard.server.service.cf.cache.CalculatedFieldEntityProfileCache; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.component.ComponentDiscoveryService; import org.thingsboard.server.service.edge.rpc.EdgeRpcService; import org.thingsboard.server.service.entitiy.entityview.TbEntityViewService; @@ -121,13 +132,18 @@ import org.thingsboard.server.service.state.DeviceStateService; import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import org.thingsboard.server.service.transport.TbCoreToTransportService; +import org.thingsboard.server.utils.DebugModeRateLimitsConfig; import java.io.PrintWriter; import java.io.StringWriter; +import java.util.Map; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; @Slf4j @Component @@ -156,6 +172,18 @@ public class ActorSystemContext { } }; + private static final FutureCallback CALCULATED_FIELD_DEBUG_EVENT_ERROR_CALLBACK = new FutureCallback<>() { + @Override + public void onSuccess(@Nullable Void event) { + + } + + @Override + public void onFailure(Throwable th) { + log.error("Could not save debug Event for Calculated Field", th); + } + }; + private final ConcurrentMap debugPerTenantLimits = new ConcurrentHashMap<>(); public ConcurrentMap getDebugPerTenantLimits() { @@ -206,7 +234,7 @@ public class ActorSystemContext { @Autowired(required = false) @Getter - private RuleEngineDeviceStateManager deviceStateManager; + private DeviceStateManager deviceStateManager; @Autowired @Getter @@ -289,6 +317,7 @@ public class ActorSystemContext { @Getter private TbEntityViewService tbEntityViewService; + @Lazy @Autowired @Getter private TelemetrySubscriptionService tsSubService; @@ -394,15 +423,15 @@ public class ActorSystemContext { @Getter private SlackService slackService; + @Autowired + @Getter + private CalculatedFieldService calculatedFieldService; + @Lazy @Autowired(required = false) @Getter private ClaimDevicesService claimDevicesService; - @Autowired - @Getter - private JsInvokeStats jsInvokeStats; - //TODO: separate context for TbCore and TbRuleEngine @Autowired(required = false) @Getter @@ -416,6 +445,21 @@ public class ActorSystemContext { @Getter private TbCoreToTransportService tbCoreToTransportService; + @Lazy + @Autowired(required = false) + @Getter + private ApiLimitService apiLimitService; + + @Lazy + @Autowired(required = false) + @Getter + private RateLimitService rateLimitService; + + @Lazy + @Autowired(required = false) + @Getter + private DebugModeRateLimitsConfig debugModeRateLimitsConfig; + /** * The following Service will be null if we operate in tb-core mode */ @@ -487,9 +531,29 @@ public class ActorSystemContext { @Getter private EntityService entityService; + @Lazy + @Autowired(required = false) + @Getter + private CalculatedFieldProcessingService calculatedFieldProcessingService; + + @Lazy + @Autowired(required = false) + @Getter + private CalculatedFieldStateService calculatedFieldStateService; + + @Lazy + @Autowired(required = false) + @Getter + private CalculatedFieldQueueService calculatedFieldQueueService; + + @Lazy + @Autowired(required = false) + @Getter + private CalculatedFieldEntityProfileCache calculatedFieldEntityProfileCache; + @Value("${actors.session.max_concurrent_sessions_per_device:1}") @Getter - private long maxConcurrentSessionsPerDevice; + private int maxConcurrentSessionsPerDevice; @Value("${actors.session.sync.timeout:10000}") @Getter @@ -527,17 +591,6 @@ public class ActorSystemContext { this.localCacheType = "caffeine".equals(cacheType); } - @Scheduled(fixedDelayString = "${actors.statistics.js_print_interval_ms}") - public void printStats() { - if (statisticsEnabled) { - if (jsInvokeStats.getRequests() > 0 || jsInvokeStats.getResponses() > 0 || jsInvokeStats.getFailures() > 0) { - log.info("Rule Engine JS Invoke Stats: requests [{}] responses [{}] failures [{}]", - jsInvokeStats.getRequests(), jsInvokeStats.getResponses(), jsInvokeStats.getFailures()); - jsInvokeStats.reset(); - } - } - } - @Value("${actors.tenant.create_components_on_init:true}") @Getter private boolean tenantComponentsInitEnabled; @@ -558,14 +611,6 @@ public class ActorSystemContext { @Getter private long sessionReportTimeout; - @Value("${actors.rule.chain.debug_mode_rate_limits_per_tenant.enabled:true}") - @Getter - private boolean debugPerTenantEnabled; - - @Value("${actors.rule.chain.debug_mode_rate_limits_per_tenant.configuration:50000:3600}") - @Getter - private String debugPerTenantLimitsConfiguration; - @Value("${actors.rpc.submit_strategy:BURST}") @Getter private String rpcSubmitStrategy; @@ -590,6 +635,10 @@ public class ActorSystemContext { @Getter private String deviceStateNodeRateLimitConfig; + @Value("${actors.calculated_fields.calculation_timeout:5}") + @Getter + private long cfCalculationResultTimeout; + @Getter @Setter private TbActorSystem actorSystem; @@ -719,9 +768,9 @@ public class ActorSystemContext { } private boolean checkLimits(TenantId tenantId, TbMsg tbMsg, Throwable error) { - if (debugPerTenantEnabled) { + if (debugModeRateLimitsConfig.isRuleChainDebugPerTenantLimitsEnabled()) { DebugTbRateLimits debugTbRateLimits = debugPerTenantLimits.computeIfAbsent(tenantId, id -> - new DebugTbRateLimits(new TbRateLimits(debugPerTenantLimitsConfiguration), false)); + new DebugTbRateLimits(new TbRateLimits(debugModeRateLimitsConfig.getRuleChainDebugPerTenantLimitsConfiguration()), false)); if (!debugTbRateLimits.getTbRateLimits().tryConsume()) { if (!debugTbRateLimits.isRuleChainEventSaved()) { @@ -751,6 +800,51 @@ public class ActorSystemContext { Futures.addCallback(future, RULE_CHAIN_DEBUG_EVENT_ERROR_CALLBACK, MoreExecutors.directExecutor()); } + public void persistCalculatedFieldDebugEvent(TenantId tenantId, CalculatedFieldId calculatedFieldId, EntityId entityId, Map arguments, UUID tbMsgId, TbMsgType tbMsgType, String result, String errorMessage) { + if (checkLimits(tenantId)) { + try { + CalculatedFieldDebugEvent.CalculatedFieldDebugEventBuilder eventBuilder = CalculatedFieldDebugEvent.builder() + .tenantId(tenantId) + .entityId(calculatedFieldId.getId()) + .serviceId(getServiceId()) + .calculatedFieldId(calculatedFieldId) + .eventEntity(entityId); + if (tbMsgId != null) { + eventBuilder.msgId(tbMsgId); + } + if (tbMsgType != null) { + eventBuilder.msgType(tbMsgType.name()); + } + if (arguments != null) { + eventBuilder.arguments(JacksonUtil.toString( + arguments.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().toTbelCfArg())) + )); + } + if (result != null) { + eventBuilder.result(result); + } + if (errorMessage != null) { + eventBuilder.error(errorMessage); + } + + ListenableFuture future = eventService.saveAsync(eventBuilder.build()); + Futures.addCallback(future, CALCULATED_FIELD_DEBUG_EVENT_ERROR_CALLBACK, MoreExecutors.directExecutor()); + } catch (IllegalArgumentException ex) { + log.warn("Failed to persist calculated field debug message", ex); + } + } + } + + private boolean checkLimits(TenantId tenantId) { + if (debugModeRateLimitsConfig.isCalculatedFieldDebugPerTenantLimitsEnabled() && + !rateLimitService.checkRateLimit(LimitedApi.CALCULATED_FIELD_DEBUG_EVENTS, (Object) tenantId, debugModeRateLimitsConfig.getCalculatedFieldDebugPerTenantLimitsConfiguration())) { + log.trace("[{}] Calculated field debug event limits exceeded!", tenantId); + return false; + } + return true; + } + public static Exception toException(Throwable error) { return Exception.class.isInstance(error) ? (Exception) error : new Exception(error); } @@ -763,9 +857,9 @@ public class ActorSystemContext { appActor.tellWithHighPriority(tbActorMsg); } - public void schedulePeriodicMsgWithDelay(TbActorRef ctx, TbActorMsg msg, long delayInMs, long periodInMs) { + public ScheduledFuture schedulePeriodicMsgWithDelay(TbActorRef ctx, TbActorMsg msg, long delayInMs, long periodInMs) { log.debug("Scheduling periodic msg {} every {} ms with delay {} ms", msg, periodInMs, delayInMs); - getScheduler().scheduleWithFixedDelay(() -> ctx.tell(msg), delayInMs, periodInMs, TimeUnit.MILLISECONDS); + return getScheduler().scheduleWithFixedDelay(() -> ctx.tell(msg), delayInMs, periodInMs, TimeUnit.MILLISECONDS); } public void scheduleMsgWithDelay(TbActorRef ctx, TbActorMsg msg, long delayInMs) { diff --git a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java index dc6a3bcf5e..904547a2b6 100644 --- a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java @@ -36,6 +36,7 @@ import org.thingsboard.server.common.data.page.PageDataIterable; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.TbActorMsg; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; import org.thingsboard.server.common.msg.aware.TenantAwareMsg; import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.msg.queue.QueueToRuleEngineMsg; @@ -87,6 +88,7 @@ public class AppActor extends ContextAwareActor { case APP_INIT_MSG: break; case PARTITION_CHANGE_MSG: + case CF_PARTITIONS_CHANGE_MSG: ctx.broadcastToChildren(msg, true); break; case COMPONENT_LIFE_CYCLE_MSG: @@ -111,6 +113,17 @@ public class AppActor extends ContextAwareActor { case SESSION_TIMEOUT_MSG: ctx.broadcastToChildrenByType(msg, EntityType.TENANT); break; + case CF_INIT_MSG: + case CF_LINK_INIT_MSG: + case CF_STATE_RESTORE_MSG: + //TODO: use priority from the message body. For example, messages about CF lifecycle are important and Device lifecycle are not. + // same for the Linked telemetry. + onToCalculatedFieldSystemActorMsg((ToCalculatedFieldSystemMsg) msg, true); + break; + case CF_TELEMETRY_MSG: + case CF_LINKED_TELEMETRY_MSG: + onToCalculatedFieldSystemActorMsg((ToCalculatedFieldSystemMsg) msg, false); + break; default: return false; } @@ -175,6 +188,19 @@ public class AppActor extends ContextAwareActor { } } + private void onToCalculatedFieldSystemActorMsg(ToCalculatedFieldSystemMsg msg, boolean priority) { + getOrCreateTenantActor(msg.getTenantId()).ifPresentOrElse(tenantActor -> { + if (priority) { + tenantActor.tellWithHighPriority(msg); + } else { + tenantActor.tell(msg); + } + }, () -> { + msg.getCallback().onSuccess(); + }); + } + + private void onToDeviceActorMsg(TenantAwareMsg msg, boolean priority) { getOrCreateTenantActor(msg.getTenantId()).ifPresentOrElse(tenantActor -> { if (priority) { diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/AbstractCalculatedFieldActor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/AbstractCalculatedFieldActor.java new file mode 100644 index 0000000000..6cf34599de --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/AbstractCalculatedFieldActor.java @@ -0,0 +1,70 @@ +/** + * 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. + */ +package org.thingsboard.server.actors.calculatedField; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.DebugModeUtil; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.service.ContextAwareActor; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.TbActorMsg; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; + +@Slf4j +public abstract class AbstractCalculatedFieldActor extends ContextAwareActor { + + protected final TenantId tenantId; + + public AbstractCalculatedFieldActor(ActorSystemContext systemContext, TenantId tenantId) { + super(systemContext); + this.tenantId = tenantId; + } + + @Override + protected boolean doProcess(TbActorMsg msg) { + if (msg instanceof ToCalculatedFieldSystemMsg cfm) { + Exception cause; + try { + return doProcessCfMsg(cfm); + } catch (CalculatedFieldException cfe) { + if (DebugModeUtil.isDebugFailuresAvailable(cfe.getCtx().getCalculatedField())) { + String message; + if (cfe.getErrorMessage() != null) { + message = cfe.getErrorMessage(); + } else if (cfe.getCause() != null) { + message = cfe.getCause().getMessage(); + } else { + message = "N/A"; + } + systemContext.persistCalculatedFieldDebugEvent(tenantId, cfe.getCtx().getCfId(), cfe.getEventEntity(), cfe.getArguments(), cfe.getMsgId(), cfe.getMsgType(), null, message); + } + cause = cfe.getCause(); + } catch (Exception e) { + logProcessingException(e); + cause = e; + } + cfm.getCallback().onFailure(cause); + return true; + } else { + return false; + } + } + + abstract void logProcessingException(Exception e); + + abstract boolean doProcessCfMsg(ToCalculatedFieldSystemMsg msg) throws CalculatedFieldException; + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java new file mode 100644 index 0000000000..350a5776cf --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java @@ -0,0 +1,81 @@ +/** + * 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. + */ +package org.thingsboard.server.actors.calculatedField; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActorCtx; +import org.thingsboard.server.actors.TbActorException; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; +import org.thingsboard.server.common.msg.cf.CalculatedFieldPartitionChangeMsg; + +@Slf4j +public class CalculatedFieldEntityActor extends AbstractCalculatedFieldActor { + + private final CalculatedFieldEntityMessageProcessor processor; + + CalculatedFieldEntityActor(ActorSystemContext systemContext, TenantId tenantId, EntityId entityId) { + super(systemContext, tenantId); + this.processor = new CalculatedFieldEntityMessageProcessor(systemContext, tenantId, entityId); + } + + @Override + public void init(TbActorCtx ctx) throws TbActorException { + super.init(ctx); + log.debug("[{}][{}] Starting CF entity actor.", processor.tenantId, processor.entityId); + try { + processor.init(ctx); + log.debug("[{}][{}] CF entity actor started.", processor.tenantId, processor.entityId); + } catch (Exception e) { + log.warn("[{}][{}] Unknown failure", processor.tenantId, processor.entityId, e); + throw new TbActorException("Failed to initialize CF entity actor", e); + } + } + + @Override + protected boolean doProcessCfMsg(ToCalculatedFieldSystemMsg msg) throws CalculatedFieldException { + switch (msg.getMsgType()) { + case CF_PARTITIONS_CHANGE_MSG: + processor.process((CalculatedFieldPartitionChangeMsg) msg); + break; + case CF_STATE_RESTORE_MSG: + processor.process((CalculatedFieldStateRestoreMsg) msg); + break; + case CF_ENTITY_INIT_CF_MSG: + processor.process((EntityInitCalculatedFieldMsg) msg); + break; + case CF_ENTITY_DELETE_MSG: + processor.process((CalculatedFieldEntityDeleteMsg) msg); + break; + case CF_ENTITY_TELEMETRY_MSG: + processor.process((EntityCalculatedFieldTelemetryMsg) msg); + break; + case CF_LINKED_TELEMETRY_MSG: + processor.process((EntityCalculatedFieldLinkedTelemetryMsg) msg); + break; + default: + return false; + } + return true; + } + + @Override + void logProcessingException(Exception e) { + log.warn("[{}][{}] Processing failure", tenantId, processor.entityId, e); + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActorCreator.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActorCreator.java new file mode 100644 index 0000000000..6dc2f26050 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActorCreator.java @@ -0,0 +1,50 @@ +/** + * 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. + */ +package org.thingsboard.server.actors.calculatedField; + +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActor; +import org.thingsboard.server.actors.TbActorId; +import org.thingsboard.server.actors.TbCalculatedFieldEntityActorId; +import org.thingsboard.server.actors.TbEntityActorId; +import org.thingsboard.server.actors.device.DeviceActor; +import org.thingsboard.server.actors.service.ContextBasedCreator; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; + +public class CalculatedFieldEntityActorCreator extends ContextBasedCreator { + + private final TenantId tenantId; + private final EntityId entityId; + + public CalculatedFieldEntityActorCreator(ActorSystemContext context, TenantId tenantId, EntityId entityId) { + super(context); + this.tenantId = tenantId; + this.entityId = entityId; + } + + @Override + public TbActorId createActorId() { + return new TbCalculatedFieldEntityActorId(entityId); + } + + @Override + public TbActor createActor() { + return new CalculatedFieldEntityActor(context, tenantId, entityId); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityDeleteMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityDeleteMsg.java new file mode 100644 index 0000000000..3ca6e8596a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityDeleteMsg.java @@ -0,0 +1,44 @@ +/** + * 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. + */ +package org.thingsboard.server.actors.calculatedField; + +import lombok.Data; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; +import org.thingsboard.server.common.msg.queue.TbCallback; + +@Data +public class CalculatedFieldEntityDeleteMsg implements ToCalculatedFieldSystemMsg { + + private final TenantId tenantId; + private final EntityId entityId; + private final TbCallback callback; + + public CalculatedFieldEntityDeleteMsg(TenantId tenantId, + EntityId entityId, + TbCallback callback) { + this.tenantId = tenantId; + this.entityId = entityId; + this.callback = callback; + } + + @Override + public MsgType getMsgType() { + return MsgType.CF_ENTITY_DELETE_MSG; + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java new file mode 100644 index 0000000000..a185b71d56 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java @@ -0,0 +1,448 @@ +/** + * 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. + */ +package org.thingsboard.server.actors.calculatedField; + +import com.google.common.util.concurrent.ListenableFuture; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.DebugModeUtil; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActorCtx; +import org.thingsboard.server.actors.shared.AbstractContextAwareMsgProcessor; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.msg.cf.CalculatedFieldPartitionChangeMsg; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.gen.transport.TransportProtos.AttributeScopeProto; +import org.thingsboard.server.gen.transport.TransportProtos.AttributeValueProto; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; +import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto; +import org.thingsboard.server.service.cf.CalculatedFieldProcessingService; +import org.thingsboard.server.service.cf.CalculatedFieldResult; +import org.thingsboard.server.service.cf.CalculatedFieldStateService; +import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + + +/** + * @author Andrew Shvayka + */ +@Slf4j +public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareMsgProcessor { + // (1 for result persistence + 1 for the state persistence ) + public static final int CALLBACKS_PER_CF = 2; + + final TenantId tenantId; + final EntityId entityId; + final CalculatedFieldProcessingService cfService; + final CalculatedFieldStateService cfStateService; + + TbActorCtx ctx; + Map states = new HashMap<>(); + + CalculatedFieldEntityMessageProcessor(ActorSystemContext systemContext, TenantId tenantId, EntityId entityId) { + super(systemContext); + this.tenantId = tenantId; + this.entityId = entityId; + this.cfService = systemContext.getCalculatedFieldProcessingService(); + this.cfStateService = systemContext.getCalculatedFieldStateService(); + } + + void init(TbActorCtx ctx) { + this.ctx = ctx; + } + + public void process(CalculatedFieldPartitionChangeMsg msg) { + if (!systemContext.getPartitionService().resolve(ServiceType.TB_RULE_ENGINE, DataConstants.CF_QUEUE_NAME, tenantId, entityId).isMyPartition()) { + log.info("[{}] Stopping entity actor due to change partition event.", entityId); + ctx.stop(ctx.getSelf()); + } + } + + public void process(CalculatedFieldStateRestoreMsg msg) { + CalculatedFieldId cfId = msg.getId().cfId(); + log.info("[{}] [{}] Processing CF state restore msg.", msg.getId().entityId(), cfId); + if (msg.getState() != null) { + states.put(cfId, msg.getState()); + } else { + states.remove(cfId); + } + } + + public void process(EntityInitCalculatedFieldMsg msg) throws CalculatedFieldException { + log.info("[{}] Processing entity init CF msg.", msg.getCtx().getCfId()); + var ctx = msg.getCtx(); + if (msg.isForceReinit()) { + log.info("Force reinitialization of CF: [{}].", ctx.getCfId()); + states.remove(ctx.getCfId()); + } + try { + var state = getOrInitState(ctx); + if (state.isSizeOk()) { + processStateIfReady(ctx, Collections.singletonList(ctx.getCfId()), state, null, null, msg.getCallback()); + } else { + throw new RuntimeException(ctx.getSizeExceedsLimitMessage()); + } + } catch (Exception e) { + if (e instanceof CalculatedFieldException cfe) { + throw cfe; + } + throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); + } + } + + public void process(CalculatedFieldEntityDeleteMsg msg) { + log.info("[{}] Processing CF entity delete msg.", msg.getEntityId()); + if (this.entityId.equals(msg.getEntityId())) { + if (states.isEmpty()) { + msg.getCallback().onSuccess(); + } else { + MultipleTbCallback multipleTbCallback = new MultipleTbCallback(states.size(), msg.getCallback()); + states.forEach((cfId, state) -> cfStateService.removeState(new CalculatedFieldEntityCtxId(tenantId, cfId, entityId), multipleTbCallback)); + ctx.stop(ctx.getSelf()); + } + } else { + var cfId = new CalculatedFieldId(msg.getEntityId().getId()); + var state = states.remove(cfId); + if (state != null) { + cfStateService.removeState(new CalculatedFieldEntityCtxId(tenantId, cfId, entityId), msg.getCallback()); + } else { + msg.getCallback().onSuccess(); + } + } + } + + public void process(EntityCalculatedFieldTelemetryMsg msg) throws CalculatedFieldException { + log.debug("[{}] Processing CF telemetry msg.", msg.getEntityId()); + var proto = msg.getProto(); + var numberOfCallbacks = CALLBACKS_PER_CF * (msg.getEntityIdFields().size() + msg.getProfileIdFields().size()); + MultipleTbCallback callback = new MultipleTbCallback(numberOfCallbacks, msg.getCallback()); + List cfIdList = getCalculatedFieldIds(proto); + Set cfIdSet = new HashSet<>(cfIdList); + for (var ctx : msg.getEntityIdFields()) { + process(ctx, proto, cfIdSet, cfIdList, callback); + } + for (var ctx : msg.getProfileIdFields()) { + process(ctx, proto, cfIdSet, cfIdList, callback); + } + } + + public void process(EntityCalculatedFieldLinkedTelemetryMsg msg) throws CalculatedFieldException { + log.debug("[{}] Processing CF link telemetry msg.", msg.getEntityId()); + var proto = msg.getProto(); + var ctx = msg.getCtx(); + var callback = new MultipleTbCallback(CALLBACKS_PER_CF, msg.getCallback()); + try { + List cfIds = getCalculatedFieldIds(proto); + if (cfIds.contains(ctx.getCfId())) { + callback.onSuccess(CALLBACKS_PER_CF); + } else { + if (proto.getTsDataCount() > 0) { + processArgumentValuesUpdate(ctx, cfIds, callback, mapToArguments(ctx, msg.getEntityId(), proto.getTsDataList()), toTbMsgId(proto), toTbMsgType(proto)); + } else if (proto.getAttrDataCount() > 0) { + processArgumentValuesUpdate(ctx, cfIds, callback, mapToArguments(ctx, msg.getEntityId(), proto.getScope(), proto.getAttrDataList()), toTbMsgId(proto), toTbMsgType(proto)); + } else if (proto.getRemovedTsKeysCount() > 0) { + processArgumentValuesUpdate(ctx, cfIds, callback, mapToArgumentsWithFetchedValue(ctx, proto.getRemovedTsKeysList()), toTbMsgId(proto), toTbMsgType(proto)); + } else if (proto.getRemovedAttrKeysCount() > 0) { + processArgumentValuesUpdate(ctx, cfIds, callback, mapToArgumentsWithDefaultValue(ctx, msg.getEntityId(), proto.getScope(), proto.getRemovedAttrKeysList()), toTbMsgId(proto), toTbMsgType(proto)); + } else { + callback.onSuccess(CALLBACKS_PER_CF); + } + } + } catch (Exception e) { + throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); + } + } + + private void process(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, Collection cfIds, List cfIdList, MultipleTbCallback callback) throws CalculatedFieldException { + try { + if (cfIds.contains(ctx.getCfId())) { + callback.onSuccess(CALLBACKS_PER_CF); + } else { + if (proto.getTsDataCount() > 0) { + processTelemetry(ctx, proto, cfIdList, callback); + } else if (proto.getAttrDataCount() > 0) { + processAttributes(ctx, proto, cfIdList, callback); + } else if (proto.getRemovedTsKeysCount() > 0) { + processRemovedTelemetry(ctx, proto, cfIdList, callback); + } else if (proto.getRemovedAttrKeysCount() > 0) { + processRemovedAttributes(ctx, proto, cfIdList, callback); + } else { + callback.onSuccess(CALLBACKS_PER_CF); + } + } + } catch (Exception e) { + if (e instanceof CalculatedFieldException cfe) { + throw cfe; + } + throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); + } + } + + private void processTelemetry(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List cfIdList, MultipleTbCallback callback) throws CalculatedFieldException { + processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArguments(ctx, proto.getTsDataList()), toTbMsgId(proto), toTbMsgType(proto)); + } + + private void processAttributes(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List cfIdList, MultipleTbCallback callback) throws CalculatedFieldException { + processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArguments(ctx, proto.getScope(), proto.getAttrDataList()), toTbMsgId(proto), toTbMsgType(proto)); + } + + private void processRemovedTelemetry(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List cfIdList, MultipleTbCallback callback) throws CalculatedFieldException { + processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArgumentsWithFetchedValue(ctx, proto.getRemovedTsKeysList()), toTbMsgId(proto), toTbMsgType(proto)); + } + + private void processRemovedAttributes(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List cfIdList, MultipleTbCallback callback) throws CalculatedFieldException { + processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArgumentsWithDefaultValue(ctx, proto.getScope(), proto.getRemovedAttrKeysList()), toTbMsgId(proto), toTbMsgType(proto)); + } + + private void processArgumentValuesUpdate(CalculatedFieldCtx ctx, List cfIdList, MultipleTbCallback callback, + Map newArgValues, UUID tbMsgId, TbMsgType tbMsgType) throws CalculatedFieldException { + if (newArgValues.isEmpty()) { + log.info("[{}] No new argument values to process for CF.", ctx.getCfId()); + callback.onSuccess(CALLBACKS_PER_CF); + } + CalculatedFieldState state = states.get(ctx.getCfId()); + boolean justRestored = false; + if (state == null) { + state = getOrInitState(ctx); + justRestored = true; + } + if (state.isSizeOk()) { + if (state.updateState(ctx, newArgValues) || justRestored) { + cfIdList = new ArrayList<>(cfIdList); + cfIdList.add(ctx.getCfId()); + processStateIfReady(ctx, cfIdList, state, tbMsgId, tbMsgType, callback); + } else { + callback.onSuccess(CALLBACKS_PER_CF); + } + } else { + throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).errorMessage(ctx.getSizeExceedsLimitMessage()).build(); + } + } + + @SneakyThrows + private CalculatedFieldState getOrInitState(CalculatedFieldCtx ctx) { + CalculatedFieldState state = states.get(ctx.getCfId()); + if (state != null) { + return state; + } else { + ListenableFuture stateFuture = systemContext.getCalculatedFieldProcessingService().fetchStateFromDb(ctx, entityId); + // Ugly but necessary. We do not expect to often fetch data from DB. Only once per pair lifetime. + // This call happens while processing the CF pack from the queue consumer. So the timeout should be relatively low. + // Alternatively, we can fetch the state outside the actor system and push separate command to create this actor, + // but this will significantly complicate the code. + state = stateFuture.get(1, TimeUnit.MINUTES); + state.checkStateSize(new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId), ctx.getMaxStateSize()); + states.put(ctx.getCfId(), state); + } + return state; + } + + private void processStateIfReady(CalculatedFieldCtx ctx, List cfIdList, CalculatedFieldState state, UUID tbMsgId, TbMsgType tbMsgType, TbCallback callback) throws CalculatedFieldException { + CalculatedFieldEntityCtxId ctxId = new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId); + boolean stateSizeChecked = false; + try { + if (ctx.isInitialized() && state.isReady()) { + CalculatedFieldResult calculationResult = state.performCalculation(ctx).get(systemContext.getCfCalculationResultTimeout(), TimeUnit.SECONDS); + state.checkStateSize(ctxId, ctx.getMaxStateSize()); + stateSizeChecked = true; + if (state.isSizeOk()) { + if (!calculationResult.isEmpty()) { + cfService.pushMsgToRuleEngine(tenantId, entityId, calculationResult, cfIdList, callback); + } else { + callback.onSuccess(); + } + if (DebugModeUtil.isDebugAllAvailable(ctx.getCalculatedField())) { + systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, JacksonUtil.writeValueAsString(calculationResult.getResult()), null); + } + } + } + } catch (Exception e) { + throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).msgId(tbMsgId).msgType(tbMsgType).arguments(state.getArguments()).cause(e).build(); + } finally { + if (!stateSizeChecked) { + state.checkStateSize(ctxId, ctx.getMaxStateSize()); + } + if (state.isSizeOk()) { + cfStateService.persistState(ctxId, state, callback); + } else { + removeStateAndRaiseSizeException(ctxId, CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).errorMessage(ctx.getSizeExceedsLimitMessage()).build(), callback); + } + } + } + + private void removeStateAndRaiseSizeException(CalculatedFieldEntityCtxId ctxId, CalculatedFieldException ex, TbCallback callback) throws CalculatedFieldException { + // We remove the state, but remember that it is over-sized in a local map. + cfStateService.removeState(ctxId, new TbCallback() { + @Override + public void onSuccess() { + callback.onFailure(ex); + } + + @Override + public void onFailure(Throwable t) { + callback.onFailure(ex); + } + }); + throw ex; + } + + private Map mapToArguments(CalculatedFieldCtx ctx, List data) { + return mapToArguments(ctx.getMainEntityArguments(), data); + } + + private Map mapToArguments(CalculatedFieldCtx ctx, EntityId entityId, List data) { + var argNames = ctx.getLinkedEntityArguments().get(entityId); + if (argNames.isEmpty()) { + return Collections.emptyMap(); + } + return mapToArguments(argNames, data); + } + + private Map mapToArguments(Map argNames, List data) { + if (argNames.isEmpty()) { + return Collections.emptyMap(); + } + Map arguments = new HashMap<>(); + for (TsKvProto item : data) { + ReferencedEntityKey key = new ReferencedEntityKey(item.getKv().getKey(), ArgumentType.TS_LATEST, null); + String argName = argNames.get(key); + if (argName != null) { + arguments.put(argName, new SingleValueArgumentEntry(item)); + } + key = new ReferencedEntityKey(item.getKv().getKey(), ArgumentType.TS_ROLLING, null); + argName = argNames.get(key); + if (argName != null) { + arguments.put(argName, new SingleValueArgumentEntry(item)); + } + } + return arguments; + } + + private Map mapToArguments(CalculatedFieldCtx ctx, AttributeScopeProto scope, List attrDataList) { + return mapToArguments(ctx.getMainEntityArguments(), scope, attrDataList); + } + + private Map mapToArguments(CalculatedFieldCtx ctx, EntityId entityId, AttributeScopeProto scope, List attrDataList) { + var argNames = ctx.getLinkedEntityArguments().get(entityId); + if (argNames.isEmpty()) { + return Collections.emptyMap(); + } + return mapToArguments(argNames, scope, attrDataList); + } + + private Map mapToArguments(Map argNames, AttributeScopeProto scope, List attrDataList) { + Map arguments = new HashMap<>(); + for (AttributeValueProto item : attrDataList) { + ReferencedEntityKey key = new ReferencedEntityKey(item.getKey(), ArgumentType.ATTRIBUTE, AttributeScope.valueOf(scope.name())); + String argName = argNames.get(key); + if (argName != null) { + arguments.put(argName, new SingleValueArgumentEntry(item)); + } + } + return arguments; + } + + private Map mapToArgumentsWithDefaultValue(CalculatedFieldCtx ctx, EntityId entityId, AttributeScopeProto scope, List removedAttrKeys) { + var argNames = ctx.getLinkedEntityArguments().get(entityId); + if (argNames.isEmpty()) { + return Collections.emptyMap(); + } + return mapToArgumentsWithDefaultValue(argNames, ctx.getArguments(), scope, removedAttrKeys); + } + + private Map mapToArgumentsWithDefaultValue(CalculatedFieldCtx ctx, AttributeScopeProto scope, List removedAttrKeys) { + return mapToArgumentsWithDefaultValue(ctx.getMainEntityArguments(), ctx.getArguments(), scope, removedAttrKeys); + } + + private Map mapToArgumentsWithDefaultValue(Map argNames, Map configArguments, AttributeScopeProto scope, List removedAttrKeys) { + Map arguments = new HashMap<>(); + for (String removedKey : removedAttrKeys) { + ReferencedEntityKey key = new ReferencedEntityKey(removedKey, ArgumentType.ATTRIBUTE, AttributeScope.valueOf(scope.name())); + String argName = argNames.get(key); + if (argName != null) { + Argument argument = configArguments.get(argName); + String defaultValue = (argument != null) ? argument.getDefaultValue() : null; + arguments.put(argName, StringUtils.isNotEmpty(defaultValue) + ? new SingleValueArgumentEntry(System.currentTimeMillis(), new StringDataEntry(removedKey, defaultValue), null) + : new SingleValueArgumentEntry()); + + } + } + return arguments; + } + + private Map mapToArgumentsWithFetchedValue(CalculatedFieldCtx ctx, List removedTelemetryKeys) { + Map deletedArguments = ctx.getArguments().entrySet().stream() + .filter(entry -> removedTelemetryKeys.contains(entry.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + Map fetchedArgs = cfService.fetchArgsFromDb(tenantId, entityId, deletedArguments); + + fetchedArgs.values().forEach(arg -> arg.setForceResetPrevious(true)); + return fetchedArgs; + } + + private static List getCalculatedFieldIds(CalculatedFieldTelemetryMsgProto proto) { + List cfIds = new LinkedList<>(); + for (var cfId : proto.getPreviousCalculatedFieldsList()) { + cfIds.add(new CalculatedFieldId(new UUID(cfId.getCalculatedFieldIdMSB(), cfId.getCalculatedFieldIdLSB()))); + } + return cfIds; + } + + private UUID toTbMsgId(CalculatedFieldTelemetryMsgProto proto) { + if (proto.getTbMsgIdMSB() != 0 && proto.getTbMsgIdLSB() != 0) { + return new UUID(proto.getTbMsgIdMSB(), proto.getTbMsgIdLSB()); + } + return null; + } + + private TbMsgType toTbMsgType(CalculatedFieldTelemetryMsgProto proto) { + if (!proto.getTbMsgType().isEmpty()) { + return TbMsgType.valueOf(proto.getTbMsgType()); + } + return null; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldException.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldException.java new file mode 100644 index 0000000000..70c8dfbfd2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldException.java @@ -0,0 +1,40 @@ +/** + * 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. + */ +package org.thingsboard.server.actors.calculatedField; + +import lombok.Builder; +import lombok.Getter; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; + +import java.util.Map; +import java.util.UUID; + +@Getter +@Builder +public class CalculatedFieldException extends Exception { + + private final CalculatedFieldCtx ctx; + private final EntityId eventEntity; + private final UUID msgId; + private final TbMsgType msgType; + private Map arguments; + private String errorMessage; + private Exception cause; + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldLinkedTelemetryMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldLinkedTelemetryMsg.java new file mode 100644 index 0000000000..3e0fba2627 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldLinkedTelemetryMsg.java @@ -0,0 +1,40 @@ +/** + * 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. + */ +package org.thingsboard.server.actors.calculatedField; + +import lombok.Data; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldLinkedTelemetryMsgProto; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; + +@Data +public class CalculatedFieldLinkedTelemetryMsg implements ToCalculatedFieldSystemMsg { + + private final TenantId tenantId; + private final EntityId entityId; + private final CalculatedFieldLinkedTelemetryMsgProto proto; + private final TbCallback callback; + + + @Override + public MsgType getMsgType() { + return MsgType.CF_LINKED_TELEMETRY_MSG; + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActor.java new file mode 100644 index 0000000000..a5c935e83f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActor.java @@ -0,0 +1,90 @@ +/** + * 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. + */ +package org.thingsboard.server.actors.calculatedField; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActorCtx; +import org.thingsboard.server.actors.TbActorException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; +import org.thingsboard.server.common.msg.cf.CalculatedFieldEntityLifecycleMsg; +import org.thingsboard.server.common.msg.cf.CalculatedFieldInitMsg; +import org.thingsboard.server.common.msg.cf.CalculatedFieldLinkInitMsg; +import org.thingsboard.server.common.msg.cf.CalculatedFieldPartitionChangeMsg; + +/** + * Created by ashvayka on 15.03.18. + */ +@Slf4j +public class CalculatedFieldManagerActor extends AbstractCalculatedFieldActor { + + private final CalculatedFieldManagerMessageProcessor processor; + + public CalculatedFieldManagerActor(ActorSystemContext systemContext, TenantId tenantId) { + super(systemContext, tenantId); + this.processor = new CalculatedFieldManagerMessageProcessor(systemContext, tenantId); + } + + @Override + public void init(TbActorCtx ctx) throws TbActorException { + super.init(ctx); + log.debug("[{}] Starting CF manager actor.", processor.tenantId); + try { + processor.init(ctx); + log.debug("[{}] CF manager actor started.", processor.tenantId); + } catch (Exception e) { + log.warn("[{}] Unknown failure", processor.tenantId, e); + throw new TbActorException("Failed to initialize manager actor", e); + } + } + + @Override + protected boolean doProcessCfMsg(ToCalculatedFieldSystemMsg msg) throws CalculatedFieldException { + switch (msg.getMsgType()) { + case CF_PARTITIONS_CHANGE_MSG: + processor.onPartitionChange((CalculatedFieldPartitionChangeMsg) msg); + break; + case CF_INIT_MSG: + processor.onFieldInitMsg((CalculatedFieldInitMsg) msg); + break; + case CF_LINK_INIT_MSG: + processor.onLinkInitMsg((CalculatedFieldLinkInitMsg) msg); + break; + case CF_STATE_RESTORE_MSG: + processor.onStateRestoreMsg((CalculatedFieldStateRestoreMsg) msg); + break; + case CF_ENTITY_LIFECYCLE_MSG: + processor.onEntityLifecycleMsg((CalculatedFieldEntityLifecycleMsg) msg); + break; + case CF_TELEMETRY_MSG: + processor.onTelemetryMsg((CalculatedFieldTelemetryMsg) msg); + break; + case CF_LINKED_TELEMETRY_MSG: + processor.onLinkedTelemetryMsg((CalculatedFieldLinkedTelemetryMsg) msg); + break; + default: + return false; + } + return true; + } + + @Override + void logProcessingException(Exception e) { + log.warn("[{}] Processing failure", tenantId, e); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActorCreator.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActorCreator.java new file mode 100644 index 0000000000..99bf3cdbe9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActorCreator.java @@ -0,0 +1,46 @@ +/** + * 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. + */ +package org.thingsboard.server.actors.calculatedField; + +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActor; +import org.thingsboard.server.actors.TbActorId; +import org.thingsboard.server.actors.TbEntityActorId; +import org.thingsboard.server.actors.TbStringActorId; +import org.thingsboard.server.actors.service.ContextBasedCreator; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; + +public class CalculatedFieldManagerActorCreator extends ContextBasedCreator { + + private final TenantId tenantId; + + public CalculatedFieldManagerActorCreator(ActorSystemContext context, TenantId tenantId) { + super(context); + this.tenantId = tenantId; + } + + @Override + public TbActorId createActorId() { + return new TbStringActorId("CFM|" + tenantId); + } + + @Override + public TbActor createActor() { + return new CalculatedFieldManagerActor(context, tenantId); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java new file mode 100644 index 0000000000..fc48e9ad3e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java @@ -0,0 +1,491 @@ +/** + * 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. + */ +package org.thingsboard.server.actors.calculatedField; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActorCtx; +import org.thingsboard.server.actors.TbActorRef; +import org.thingsboard.server.actors.TbCalculatedFieldEntityActorId; +import org.thingsboard.server.actors.service.DefaultActorService; +import org.thingsboard.server.actors.shared.AbstractContextAwareMsgProcessor; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.cf.CalculatedFieldEntityLifecycleMsg; +import org.thingsboard.server.common.msg.cf.CalculatedFieldInitMsg; +import org.thingsboard.server.common.msg.cf.CalculatedFieldLinkInitMsg; +import org.thingsboard.server.common.msg.cf.CalculatedFieldPartitionChangeMsg; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.dao.cf.CalculatedFieldService; +import org.thingsboard.server.service.cf.CalculatedFieldProcessingService; +import org.thingsboard.server.service.cf.CalculatedFieldStateService; +import org.thingsboard.server.service.cf.cache.CalculatedFieldEntityProfileCache; +import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; +import org.thingsboard.server.service.profile.TbAssetProfileCache; +import org.thingsboard.server.service.profile.TbDeviceProfileCache; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.thingsboard.server.utils.CalculatedFieldUtils.fromProto; + + +/** + * @author Andrew Shvayka + */ +@Slf4j +public class CalculatedFieldManagerMessageProcessor extends AbstractContextAwareMsgProcessor { + + private final Map calculatedFields = new HashMap<>(); + private final Map> entityIdCalculatedFields = new HashMap<>(); + private final Map> entityIdCalculatedFieldLinks = new HashMap<>(); + + private final CalculatedFieldProcessingService cfExecService; + private final CalculatedFieldStateService cfStateService; + private final CalculatedFieldEntityProfileCache cfEntityCache; + private final CalculatedFieldService cfDaoService; + private final TbAssetProfileCache assetProfileCache; + private final TbDeviceProfileCache deviceProfileCache; + protected final TenantId tenantId; + + protected TbActorCtx ctx; + + CalculatedFieldManagerMessageProcessor(ActorSystemContext systemContext, TenantId tenantId) { + super(systemContext); + this.cfEntityCache = systemContext.getCalculatedFieldEntityProfileCache(); + this.cfExecService = systemContext.getCalculatedFieldProcessingService(); + this.cfStateService = systemContext.getCalculatedFieldStateService(); + this.cfDaoService = systemContext.getCalculatedFieldService(); + this.assetProfileCache = systemContext.getAssetProfileCache(); + this.deviceProfileCache = systemContext.getDeviceProfileCache(); + this.tenantId = tenantId; + } + + void init(TbActorCtx ctx) { + this.ctx = ctx; + } + + public void onFieldInitMsg(CalculatedFieldInitMsg msg) throws CalculatedFieldException { + log.info("[{}] Processing CF init message.", msg.getCf().getId()); + var cf = msg.getCf(); + var cfCtx = new CalculatedFieldCtx(cf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService()); + try { + cfCtx.init(); + } catch (Exception e) { + throw CalculatedFieldException.builder().ctx(cfCtx).eventEntity(cf.getEntityId()).cause(e).errorMessage("Failed to initialize CF context").build(); + } + calculatedFields.put(cf.getId(), cfCtx); + // We use copy on write lists to safely pass the reference to another actor for the iteration. + // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead) + entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cfCtx); + msg.getCallback().onSuccess(); + } + + public void onLinkInitMsg(CalculatedFieldLinkInitMsg msg) { + log.info("[{}] Processing CF link init message for entity [{}].", msg.getLink().getCalculatedFieldId(), msg.getLink().getEntityId()); + var link = msg.getLink(); + // We use copy on write lists to safely pass the reference to another actor for the iteration. + // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead) + entityIdCalculatedFieldLinks.computeIfAbsent(link.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(link); + msg.getCallback().onSuccess(); + } + + public void onStateRestoreMsg(CalculatedFieldStateRestoreMsg msg) { + var cfId = msg.getId().cfId(); + var calculatedField = calculatedFields.get(cfId); + + if (calculatedField != null) { + msg.getState().setRequiredArguments(calculatedField.getArgNames()); + log.debug("Pushing CF state restore msg to specific actor [{}]", msg.getId().entityId()); + getOrCreateActor(msg.getId().entityId()).tell(msg); + } else { + cfStateService.removeState(msg.getId(), msg.getCallback()); + } + } + + public void onEntityLifecycleMsg(CalculatedFieldEntityLifecycleMsg msg) throws CalculatedFieldException { + log.info("Processing entity lifecycle event: [{}] for entity: [{}]", msg.getData().getEvent(), msg.getData().getEntityId()); + var entityType = msg.getData().getEntityId().getEntityType(); + var event = msg.getData().getEvent(); + switch (entityType) { + case CALCULATED_FIELD: { + switch (event) { + case CREATED: + onCfCreated(msg.getData(), msg.getCallback()); + break; + case UPDATED: + onCfUpdated(msg.getData(), msg.getCallback()); + break; + case DELETED: + onCfDeleted(msg.getData(), msg.getCallback()); + break; + default: + msg.getCallback().onSuccess(); + break; + } + break; + } + case DEVICE: + case ASSET: { + switch (event) { + case CREATED: + onEntityCreated(msg.getData(), msg.getCallback()); + break; + case UPDATED: + onEntityUpdated(msg.getData(), msg.getCallback()); + break; + case DELETED: + onEntityDeleted(msg.getData(), msg.getCallback()); + break; + default: + msg.getCallback().onSuccess(); + break; + } + break; + } + default: { + msg.getCallback().onSuccess(); + } + } + } + + private void onEntityCreated(ComponentLifecycleMsg msg, TbCallback callback) { + EntityId entityId = msg.getEntityId(); + EntityId profileId = getProfileId(tenantId, entityId); + cfEntityCache.add(tenantId, profileId, entityId); + if (!isMyPartition(entityId, callback)) { + return; + } + var entityIdFields = getCalculatedFieldsByEntityId(entityId); + var profileIdFields = getCalculatedFieldsByEntityId(profileId); + var fieldsCount = entityIdFields.size() + profileIdFields.size(); + if (fieldsCount > 0) { + MultipleTbCallback multiCallback = new MultipleTbCallback(fieldsCount, callback); + entityIdFields.forEach(ctx -> initCfForEntity(entityId, ctx, true, multiCallback)); + profileIdFields.forEach(ctx -> initCfForEntity(entityId, ctx, true, multiCallback)); + } else { + callback.onSuccess(); + } + } + + private void onEntityUpdated(ComponentLifecycleMsg msg, TbCallback callback) { + if (msg.getOldProfileId() != null && !msg.getOldProfileId().equals(msg.getProfileId())) { + cfEntityCache.update(tenantId, msg.getOldProfileId(), msg.getProfileId(), msg.getEntityId()); + if (!isMyPartition(msg.getEntityId(), callback)) { + return; + } + var oldProfileCfs = getCalculatedFieldsByEntityId(msg.getOldProfileId()); + var newProfileCfs = getCalculatedFieldsByEntityId(msg.getProfileId()); + var fieldsCount = oldProfileCfs.size() + newProfileCfs.size(); + if (fieldsCount > 0) { + MultipleTbCallback multiCallback = new MultipleTbCallback(fieldsCount, callback); + var entityId = msg.getEntityId(); + oldProfileCfs.forEach(ctx -> deleteCfForEntity(entityId, ctx.getCfId(), multiCallback)); + newProfileCfs.forEach(ctx -> initCfForEntity(entityId, ctx, true, multiCallback)); + } else { + callback.onSuccess(); + } + } + } + + private void onEntityDeleted(ComponentLifecycleMsg msg, TbCallback callback) { + cfEntityCache.evict(tenantId, msg.getEntityId()); + if (isMyPartition(msg.getEntityId(), callback)) { + log.debug("Pushing entity lifecycle msg to specific actor [{}]", msg.getEntityId()); + getOrCreateActor(msg.getEntityId()).tell(new CalculatedFieldEntityDeleteMsg(tenantId, msg.getEntityId(), callback)); + } + } + + private void onCfCreated(ComponentLifecycleMsg msg, TbCallback callback) throws CalculatedFieldException { + var cfId = new CalculatedFieldId(msg.getEntityId().getId()); + if (calculatedFields.containsKey(cfId)) { + log.warn("[{}] CF was already initialized [{}]", tenantId, cfId); + callback.onSuccess(); + } else { + var cf = cfDaoService.findById(msg.getTenantId(), cfId); + if (cf == null) { + log.warn("[{}] Failed to lookup CF by id [{}]", tenantId, cfId); + callback.onSuccess(); + } else { + var cfCtx = new CalculatedFieldCtx(cf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService()); + try { + cfCtx.init(); + } catch (Exception e) { + throw CalculatedFieldException.builder().ctx(cfCtx).eventEntity(cf.getEntityId()).cause(e).errorMessage("Failed to initialize CF context").build(); + } + calculatedFields.put(cf.getId(), cfCtx); + // We use copy on write lists to safely pass the reference to another actor for the iteration. + // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead) + entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cfCtx); + addLinks(cf); + initCf(cfCtx, callback, false); + } + } + } + + private void onCfUpdated(ComponentLifecycleMsg msg, TbCallback callback) throws CalculatedFieldException { + var cfId = new CalculatedFieldId(msg.getEntityId().getId()); + var oldCfCtx = calculatedFields.get(cfId); + if (oldCfCtx == null) { + onCfCreated(msg, callback); + } else { + var newCf = cfDaoService.findById(msg.getTenantId(), cfId); + if (newCf == null) { + log.warn("[{}] Failed to lookup CF by id [{}]", tenantId, cfId); + callback.onSuccess(); + } else { + var newCfCtx = new CalculatedFieldCtx(newCf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService()); + try { + newCfCtx.init(); + } catch (Exception e) { + throw CalculatedFieldException.builder().ctx(newCfCtx).eventEntity(newCfCtx.getEntityId()).cause(e).errorMessage("Failed to initialize CF context").build(); + } + calculatedFields.put(newCf.getId(), newCfCtx); + List oldCfList = entityIdCalculatedFields.get(newCf.getEntityId()); + List newCfList = new CopyOnWriteArrayList<>(); + boolean found = false; + for (CalculatedFieldCtx oldCtx : oldCfList) { + if (oldCtx.getCfId().equals(newCf.getId())) { + newCfList.add(newCfCtx); + found = true; + } else { + newCfList.add(oldCtx); + } + } + if (!found) { + newCfList.add(newCfCtx); + } + entityIdCalculatedFields.put(newCf.getEntityId(), newCfList); + + deleteLinks(oldCfCtx); + addLinks(newCf); + + // We use copy on write lists to safely pass the reference to another actor for the iteration. + // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead) + var stateChanges = newCfCtx.hasStateChanges(oldCfCtx); + if (stateChanges || newCfCtx.hasOtherSignificantChanges(oldCfCtx)) { + initCf(newCfCtx, callback, stateChanges); + } else { + callback.onSuccess(); + } + } + } + } + + private void onCfDeleted(ComponentLifecycleMsg msg, TbCallback callback) { + var cfId = new CalculatedFieldId(msg.getEntityId().getId()); + var cfCtx = calculatedFields.remove(cfId); + if (cfCtx == null) { + log.warn("[{}] CF was already deleted [{}]", tenantId, cfId); + callback.onSuccess(); + } else { + entityIdCalculatedFields.get(cfCtx.getEntityId()).remove(cfCtx); + deleteLinks(cfCtx); + + EntityId entityId = cfCtx.getEntityId(); + EntityType entityType = cfCtx.getEntityId().getEntityType(); + if (isProfileEntity(entityType)) { + var entityIds = cfEntityCache.getMyEntityIdsByProfileId(tenantId, entityId); + if (!entityIds.isEmpty()) { + //TODO: no need to do this if we cache all created actors and know which one belong to us; + var multiCallback = new MultipleTbCallback(entityIds.size(), callback); + entityIds.forEach(id -> deleteCfForEntity(id, cfId, multiCallback)); + } else { + callback.onSuccess(); + } + } else { + if (isMyPartition(entityId, callback)) { + deleteCfForEntity(entityId, cfId, callback); + } + } + } + } + + public void onTelemetryMsg(CalculatedFieldTelemetryMsg msg) { + EntityId entityId = msg.getEntityId(); + log.debug("Received telemetry msg from entity [{}]", entityId); + // 2 = 1 for CF processing + 1 for links processing + MultipleTbCallback callback = new MultipleTbCallback(2, msg.getCallback()); + // process all cfs related to entity, or it's profile; + var entityIdFields = getCalculatedFieldsByEntityId(entityId); + var profileIdFields = getCalculatedFieldsByEntityId(getProfileId(tenantId, entityId)); + if (!entityIdFields.isEmpty() || !profileIdFields.isEmpty()) { + log.debug("Pushing telemetry msg to specific actor [{}]", entityId); + getOrCreateActor(entityId).tell(new EntityCalculatedFieldTelemetryMsg(msg, entityIdFields, profileIdFields, callback)); + } else { + callback.onSuccess(); + } + // process all links (if any); + List linkedCalculatedFields = filterCalculatedFieldLinks(msg); + var linksSize = linkedCalculatedFields.size(); + if (linksSize > 0) { + cfExecService.pushMsgToLinks(msg, linkedCalculatedFields, callback); + } else { + callback.onSuccess(); + } + } + + public void onLinkedTelemetryMsg(CalculatedFieldLinkedTelemetryMsg msg) { + EntityId sourceEntityId = msg.getEntityId(); + log.debug("Received linked telemetry msg from entity [{}]", sourceEntityId); + var proto = msg.getProto(); + var linksList = proto.getLinksList(); + for (var linkProto : linksList) { + var link = fromProto(linkProto); + var targetEntityId = link.entityId(); + var targetEntityType = targetEntityId.getEntityType(); + var cf = calculatedFields.get(link.cfId()); + if (EntityType.DEVICE_PROFILE.equals(targetEntityType) || EntityType.ASSET_PROFILE.equals(targetEntityType)) { + // iterate over all entities that belong to profile and push the message for corresponding CF + var entityIds = cfEntityCache.getMyEntityIdsByProfileId(tenantId, targetEntityId); + if (!entityIds.isEmpty()) { + MultipleTbCallback callback = new MultipleTbCallback(entityIds.size(), msg.getCallback()); + var newMsg = new EntityCalculatedFieldLinkedTelemetryMsg(tenantId, sourceEntityId, proto.getMsg(), cf, callback); + entityIds.forEach(entityId -> { + log.debug("Pushing linked telemetry msg to specific actor [{}]", entityId); + getOrCreateActor(entityId).tell(newMsg); + }); + } else { + msg.getCallback().onSuccess(); + } + } else { + log.debug("Pushing linked telemetry msg to specific actor [{}]", targetEntityId); + var newMsg = new EntityCalculatedFieldLinkedTelemetryMsg(tenantId, sourceEntityId, proto.getMsg(), cf, msg.getCallback()); + getOrCreateActor(targetEntityId).tell(newMsg); + } + } + } + + private List filterCalculatedFieldLinks(CalculatedFieldTelemetryMsg msg) { + EntityId entityId = msg.getEntityId(); + var proto = msg.getProto(); + List result = new ArrayList<>(); + for (var link : getCalculatedFieldLinksByEntityId(entityId)) { + CalculatedFieldCtx ctx = calculatedFields.get(link.getCalculatedFieldId()); + if (ctx.linkMatches(entityId, proto)) { + result.add(ctx.toCalculatedFieldEntityCtxId()); + } + } + return result; + } + + private List getCalculatedFieldsByEntityId(EntityId entityId) { + if (entityId == null) { + return Collections.emptyList(); + } + var result = entityIdCalculatedFields.get(entityId); + if (result == null) { + result = Collections.emptyList(); + } + return result; + } + + private List getCalculatedFieldLinksByEntityId(EntityId entityId) { + if (entityId == null) { + return Collections.emptyList(); + } + var result = entityIdCalculatedFieldLinks.get(entityId); + if (result == null) { + result = Collections.emptyList(); + } + return result; + } + + private void initCf(CalculatedFieldCtx cfCtx, TbCallback callback, boolean forceStateReinit) { + EntityId entityId = cfCtx.getEntityId(); + EntityType entityType = cfCtx.getEntityId().getEntityType(); + if (isProfileEntity(entityType)) { + var entityIds = cfEntityCache.getMyEntityIdsByProfileId(tenantId, entityId); + if (!entityIds.isEmpty()) { + var multiCallback = new MultipleTbCallback(entityIds.size(), callback); + entityIds.forEach(id -> initCfForEntity(id, cfCtx, forceStateReinit, multiCallback)); + } else { + callback.onSuccess(); + } + } else { + if (isMyPartition(entityId, callback)) { + initCfForEntity(entityId, cfCtx, forceStateReinit, callback); + } + } + } + + private void deleteCfForEntity(EntityId entityId, CalculatedFieldId cfId, TbCallback callback) { + log.debug("Pushing delete CF msg to specific actor [{}]", entityId); + getOrCreateActor(entityId).tell(new CalculatedFieldEntityDeleteMsg(tenantId, cfId, callback)); + } + + private void initCfForEntity(EntityId entityId, CalculatedFieldCtx cfCtx, boolean forceStateReinit, TbCallback callback) { + log.debug("Pushing entity init CF msg to specific actor [{}]", entityId); + getOrCreateActor(entityId).tell(new EntityInitCalculatedFieldMsg(tenantId, cfCtx, callback, forceStateReinit)); + } + + private boolean isMyPartition(EntityId entityId, TbCallback callback) { + if (!systemContext.getPartitionService().resolve(ServiceType.TB_RULE_ENGINE, DataConstants.CF_QUEUE_NAME, tenantId, entityId).isMyPartition()) { + log.debug("[{}] Entity belongs to external partition.", entityId); + callback.onSuccess(); + return false; + } + return true; + } + + private static boolean isProfileEntity(EntityType entityType) { + return EntityType.DEVICE_PROFILE.equals(entityType) || EntityType.ASSET_PROFILE.equals(entityType); + } + + private EntityId getProfileId(TenantId tenantId, EntityId entityId) { + return switch (entityId.getEntityType()) { + case ASSET -> assetProfileCache.get(tenantId, (AssetId) entityId).getId(); + case DEVICE -> deviceProfileCache.get(tenantId, (DeviceId) entityId).getId(); + default -> null; + }; + } + + private TbActorRef getOrCreateActor(EntityId entityId) { + return ctx.getOrCreateChildActor(new TbCalculatedFieldEntityActorId(entityId), + () -> DefaultActorService.CF_ENTITY_DISPATCHER_NAME, + () -> new CalculatedFieldEntityActorCreator(systemContext, tenantId, entityId), + () -> true); + } + + private void addLinks(CalculatedField newCf) { + var newLinks = newCf.getConfiguration().buildCalculatedFieldLinks(tenantId, newCf.getEntityId(), newCf.getId()); + newLinks.forEach(link -> entityIdCalculatedFieldLinks.computeIfAbsent(link.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(link)); + } + + private void deleteLinks(CalculatedFieldCtx cfCtx) { + var oldCf = cfCtx.getCalculatedField(); + var oldLinks = oldCf.getConfiguration().buildCalculatedFieldLinks(tenantId, oldCf.getEntityId(), oldCf.getId()); + oldLinks.forEach(link -> entityIdCalculatedFieldLinks.computeIfAbsent(link.getEntityId(), id -> new CopyOnWriteArrayList<>()).remove(link)); + } + + public void onPartitionChange(CalculatedFieldPartitionChangeMsg msg) { + ctx.broadcastToChildren(msg, true); + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldStateRestoreMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldStateRestoreMsg.java new file mode 100644 index 0000000000..19be7c02fa --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldStateRestoreMsg.java @@ -0,0 +1,40 @@ +/** + * 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. + */ +package org.thingsboard.server.actors.calculatedField; + +import lombok.Data; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; +import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; + +@Data +public class CalculatedFieldStateRestoreMsg implements ToCalculatedFieldSystemMsg { + + private final CalculatedFieldEntityCtxId id; + private final CalculatedFieldState state; + + @Override + public MsgType getMsgType() { + return MsgType.CF_STATE_RESTORE_MSG; + } + + @Override + public TenantId getTenantId() { + return id.tenantId(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldTelemetryMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldTelemetryMsg.java new file mode 100644 index 0000000000..68cd149cdf --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldTelemetryMsg.java @@ -0,0 +1,39 @@ +/** + * 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. + */ +package org.thingsboard.server.actors.calculatedField; + +import lombok.Data; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; + +@Data +public class CalculatedFieldTelemetryMsg implements ToCalculatedFieldSystemMsg { + + private final TenantId tenantId; + private final EntityId entityId; + private final CalculatedFieldTelemetryMsgProto proto; + private final TbCallback callback; + + + @Override + public MsgType getMsgType() { + return MsgType.CF_TELEMETRY_MSG; + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldLinkedTelemetryMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldLinkedTelemetryMsg.java new file mode 100644 index 0000000000..b83aeae416 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldLinkedTelemetryMsg.java @@ -0,0 +1,42 @@ +/** + * 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. + */ +package org.thingsboard.server.actors.calculatedField; + +import lombok.Data; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; + +import java.util.List; + +@Data +public class EntityCalculatedFieldLinkedTelemetryMsg implements ToCalculatedFieldSystemMsg { + + private final TenantId tenantId; + private final EntityId entityId; + private final CalculatedFieldTelemetryMsgProto proto; + private final CalculatedFieldCtx ctx; + private final TbCallback callback; + + @Override + public MsgType getMsgType() { + return MsgType.CF_LINKED_TELEMETRY_MSG; + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldTelemetryMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldTelemetryMsg.java new file mode 100644 index 0000000000..8ded4b6028 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldTelemetryMsg.java @@ -0,0 +1,56 @@ +/** + * 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. + */ +package org.thingsboard.server.actors.calculatedField; + +import lombok.Data; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; + +import java.util.List; + +@Data +public class EntityCalculatedFieldTelemetryMsg implements ToCalculatedFieldSystemMsg { + + private final TenantId tenantId; + private final EntityId entityId; + private final CalculatedFieldTelemetryMsgProto proto; + // Both lists are effectively immutable in CalculatedFieldManagerMessageProcessor and must stay so. + private final List entityIdFields; + private final List profileIdFields; + private final TbCallback callback; + + public EntityCalculatedFieldTelemetryMsg(CalculatedFieldTelemetryMsg msg, + List entityIdFields, + List profileIdFields, + TbCallback callback) { + this.tenantId = msg.getTenantId(); + this.entityId = msg.getEntityId(); + this.proto = msg.getProto(); + this.entityIdFields = entityIdFields; + this.profileIdFields = profileIdFields; + this.callback = callback; + } + + @Override + public MsgType getMsgType() { + return MsgType.CF_ENTITY_TELEMETRY_MSG; + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityInitCalculatedFieldMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityInitCalculatedFieldMsg.java new file mode 100644 index 0000000000..1e8990ff8d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityInitCalculatedFieldMsg.java @@ -0,0 +1,41 @@ +/** + * 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. + */ +package org.thingsboard.server.actors.calculatedField; + +import lombok.Data; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; + +import java.util.List; + +@Data +public class EntityInitCalculatedFieldMsg implements ToCalculatedFieldSystemMsg { + + private final TenantId tenantId; + private final CalculatedFieldCtx ctx; + private final TbCallback callback; + private final boolean forceReinit; + + @Override + public MsgType getMsgType() { + return MsgType.CF_ENTITY_INIT_CF_MSG; + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/MultipleTbCallback.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/MultipleTbCallback.java new file mode 100644 index 0000000000..d1f4c9092e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/MultipleTbCallback.java @@ -0,0 +1,56 @@ +/** + * 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. + */ +package org.thingsboard.server.actors.calculatedField; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.msg.queue.TbCallback; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +public class MultipleTbCallback implements TbCallback { + @Getter + private final UUID id; + private final AtomicInteger counter; + private final TbCallback callback; + + public MultipleTbCallback(int count, TbCallback callback) { + id = UUID.randomUUID(); + this.counter = new AtomicInteger(count); + this.callback = callback; + } + + @Override + public void onSuccess() { + onSuccess(1); + } + + public void onSuccess(int number) { + log.trace("[{}][{}] onSuccess({})", id, callback.getId(), number); + if (counter.addAndGet(-number) <= 0) { + log.trace("[{}][{}] Done.", id, callback.getId()); + callback.onSuccess(); + } + } + + @Override + public void onFailure(Throwable t) { + log.warn("[{}][{}] onFailure.", id, callback.getId()); + callback.onFailure(t); + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java index a1e8434b6e..033e10ca9a 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java @@ -28,8 +28,9 @@ import org.thingsboard.rule.engine.api.NotificationCenter; import org.thingsboard.rule.engine.api.RuleEngineAlarmService; import org.thingsboard.rule.engine.api.RuleEngineApiUsageStateService; import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache; +import org.thingsboard.rule.engine.api.RuleEngineCalculatedFieldQueueService; import org.thingsboard.rule.engine.api.RuleEngineDeviceProfileCache; -import org.thingsboard.rule.engine.api.RuleEngineDeviceStateManager; +import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.RuleEngineRpcService; import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; import org.thingsboard.rule.engine.api.ScriptEngine; @@ -79,6 +80,7 @@ import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.cassandra.CassandraCluster; +import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.DeviceCredentialsService; @@ -652,27 +654,6 @@ public class DefaultTbContext implements TbContext { } } - @Override - public void logJsEvalRequest() { - if (mainCtx.isStatisticsEnabled()) { - mainCtx.getJsInvokeStats().incrementRequests(); - } - } - - @Override - public void logJsEvalResponse() { - if (mainCtx.isStatisticsEnabled()) { - mainCtx.getJsInvokeStats().incrementResponses(); - } - } - - @Override - public void logJsEvalFailure() { - if (mainCtx.isStatisticsEnabled()) { - mainCtx.getJsInvokeStats().incrementFailures(); - } - } - @Override public String getServiceId() { return mainCtx.getServiceInfoProvider().getServiceId(); @@ -724,7 +705,7 @@ public class DefaultTbContext implements TbContext { } @Override - public RuleEngineDeviceStateManager getDeviceStateManager() { + public DeviceStateManager getDeviceStateManager() { return mainCtx.getDeviceStateManager(); } @@ -896,6 +877,16 @@ public class DefaultTbContext implements TbContext { return mainCtx.getSlackService(); } + @Override + public CalculatedFieldService getCalculatedFieldService() { + return mainCtx.getCalculatedFieldService(); + } + + @Override + public RuleEngineCalculatedFieldQueueService getCalculatedFieldQueueService() { + return mainCtx.getCalculatedFieldQueueService(); + } + @Override public boolean isExternalNodeForceAck() { return mainCtx.isExternalNodeForceAck(); diff --git a/application/src/main/java/org/thingsboard/server/actors/service/ComponentActor.java b/application/src/main/java/org/thingsboard/server/actors/service/ComponentActor.java index 5778f02663..7b390cd9f3 100644 --- a/application/src/main/java/org/thingsboard/server/actors/service/ComponentActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/service/ComponentActor.java @@ -29,6 +29,9 @@ import org.thingsboard.server.common.msg.TbActorStopReason; import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.msg.queue.PartitionChangeMsg; +import java.util.Optional; +import java.util.concurrent.ScheduledFuture; + /** * @author Andrew Shvayka */ @@ -41,6 +44,7 @@ public abstract class ComponentActor statsScheduledFuture = null; public ComponentActor(ActorSystemContext systemContext, TenantId tenantId, T id) { super(systemContext); @@ -73,9 +77,9 @@ public abstract class ComponentActor x.cancel(false)); + statsScheduledFuture = null; } catch (Exception e) { log.warn("[{}][{}] Failed to stop {} processor: {}", tenantId, id, id.getEntityType(), e.getMessage()); logAndPersist("OnStop", e, true); diff --git a/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java b/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java index 6c8f253138..c2131d9e74 100644 --- a/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java +++ b/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java @@ -49,6 +49,8 @@ public class DefaultActorService extends TbApplicationEventListener schedulePeriodicMsgWithDelay(TbActorCtx ctx, TbActorMsg msg, long delayInMs, long periodInMs) { + return systemContext.schedulePeriodicMsgWithDelay(ctx, msg, delayInMs, periodInMs); } protected void scheduleMsgWithDelay(TbActorCtx ctx, TbActorMsg msg, long delayInMs) { diff --git a/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java b/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java index d48a8f7684..76e5ce7899 100644 --- a/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java @@ -27,6 +27,8 @@ import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.queue.PartitionChangeMsg; import org.thingsboard.server.common.msg.queue.RuleNodeException; +import java.util.concurrent.ScheduledFuture; + @Slf4j public abstract class ComponentMsgProcessor extends AbstractContextAwareMsgProcessor { @@ -77,8 +79,8 @@ public abstract class ComponentMsgProcessor extends Abstract start(context); } - public void scheduleStatsPersistTick(TbActorCtx context, long statsPersistFrequency) { - schedulePeriodicMsgWithDelay(context, StatsPersistTick.INSTANCE, statsPersistFrequency, statsPersistFrequency); + public ScheduledFuture scheduleStatsPersistTick(TbActorCtx context, long statsPersistFrequency) { + return schedulePeriodicMsgWithDelay(context, StatsPersistTick.INSTANCE, statsPersistFrequency, statsPersistFrequency); } protected boolean checkMsgValid(TbMsg tbMsg) { diff --git a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java index cc798616d6..846bde508d 100644 --- a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java @@ -26,6 +26,8 @@ import org.thingsboard.server.actors.TbActorNotRegisteredException; import org.thingsboard.server.actors.TbActorRef; import org.thingsboard.server.actors.TbEntityActorId; import org.thingsboard.server.actors.TbEntityTypeActorIdPredicate; +import org.thingsboard.server.actors.TbStringActorId; +import org.thingsboard.server.actors.calculatedField.CalculatedFieldManagerActorCreator; import org.thingsboard.server.actors.device.DeviceActorCreator; import org.thingsboard.server.actors.ruleChain.RuleChainManagerActor; import org.thingsboard.server.actors.service.ContextBasedCreator; @@ -44,8 +46,10 @@ import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.TbActorMsg; import org.thingsboard.server.common.msg.TbActorStopReason; import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; import org.thingsboard.server.common.msg.aware.DeviceAwareMsg; import org.thingsboard.server.common.msg.aware.RuleChainAwareMsg; +import org.thingsboard.server.common.msg.cf.CalculatedFieldEntityLifecycleMsg; import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.msg.queue.PartitionChangeMsg; import org.thingsboard.server.common.msg.queue.QueueToRuleEngineMsg; @@ -64,8 +68,8 @@ public class TenantActor extends RuleChainManagerActor { private boolean isRuleEngine; private boolean isCore; private ApiUsageState apiUsageState; - private Set deletedDevices; + private TbActorRef cfActor; private TenantActor(ActorSystemContext systemContext, TenantId tenantId) { super(systemContext, tenantId); @@ -88,6 +92,15 @@ public class TenantActor extends RuleChainManagerActor { isRuleEngine = systemContext.getServiceInfoProvider().isService(ServiceType.TB_RULE_ENGINE); if (isRuleEngine) { if (systemContext.getPartitionService().isManagedByCurrentService(tenantId)) { + try { + //TODO: IM - extend API usage to have CF Exec Enabled? Not in 4.0; + cfActor = ctx.getOrCreateChildActor(new TbStringActorId("CFM|" + tenantId), + () -> DefaultActorService.CF_MANAGER_DISPATCHER_NAME, + () -> new CalculatedFieldManagerActorCreator(systemContext, tenantId), + () -> true); + } catch (Exception e) { + log.info("[{}] Failed to init CF Actor.", tenantId, e); + } try { if (getApiUsageState().isReExecEnabled()) { log.debug("[{}] Going to init rule chains", tenantId); @@ -100,7 +113,7 @@ public class TenantActor extends RuleChainManagerActor { cantFindTenant = true; } } else { - log.info("Tenant {} is not managed by current service, skipping rule chains init", tenantId); + log.info("Tenant {} is not managed by current service, skipping rule chains and cf actor init", tenantId); } } log.debug("[{}] Tenant actor started.", tenantId); @@ -159,12 +172,34 @@ public class TenantActor extends RuleChainManagerActor { case RULE_CHAIN_TO_RULE_CHAIN_MSG: onRuleChainMsg((RuleChainAwareMsg) msg); break; + case CF_INIT_MSG: + case CF_LINK_INIT_MSG: + case CF_STATE_RESTORE_MSG: + case CF_PARTITIONS_CHANGE_MSG: + onToCalculatedFieldSystemActorMsg((ToCalculatedFieldSystemMsg) msg, true); + break; + case CF_TELEMETRY_MSG: + case CF_LINKED_TELEMETRY_MSG: + onToCalculatedFieldSystemActorMsg((ToCalculatedFieldSystemMsg) msg, false); + break; default: return false; } return true; } + private void onToCalculatedFieldSystemActorMsg(ToCalculatedFieldSystemMsg msg, boolean priority) { + if (cfActor == null) { + log.warn("[{}] CF Actor is not initialized.", tenantId); + return; + } + if (priority) { + cfActor.tellWithHighPriority(msg); + } else { + cfActor.tell(msg); + } + } + private boolean isMyPartition(EntityId entityId) { return systemContext.resolve(ServiceType.TB_CORE, tenantId, entityId).isMyPartition(); } @@ -224,11 +259,25 @@ public class TenantActor extends RuleChainManagerActor { ServiceType serviceType = msg.getServiceType(); if (ServiceType.TB_RULE_ENGINE.equals(serviceType)) { if (systemContext.getPartitionService().isManagedByCurrentService(tenantId)) { + if (cfActor == null) { + try { + //TODO: IM - extend API usage to have CF Exec Enabled? Not in 4.0; + cfActor = ctx.getOrCreateChildActor(new TbStringActorId("CFM|" + tenantId), + () -> DefaultActorService.CF_MANAGER_DISPATCHER_NAME, + () -> new CalculatedFieldManagerActorCreator(systemContext, tenantId), + () -> true); + } catch (Exception e) { + log.info("[{}] Failed to init CF Actor.", tenantId, e); + } + } if (!ruleChainsInitialized) { log.info("Tenant {} is now managed by this service, initializing rule chains", tenantId); initRuleChains(); } } else { + if (cfActor != null) { + ctx.stop(cfActor.getActorId()); + } if (ruleChainsInitialized) { log.info("Tenant {} is no longer managed by this service, stopping rule chains", tenantId); destroyRuleChains(); @@ -266,19 +315,26 @@ public class TenantActor extends RuleChainManagerActor { onToDeviceActorMsg(new DeviceDeleteMsg(tenantId, deviceId), true); deletedDevices.add(deviceId); } - if (isRuleEngine && ruleChainsInitialized) { - TbActorRef target = getEntityActorRef(msg.getEntityId()); - if (target != null) { - if (msg.getEntityId().getEntityType() == EntityType.RULE_CHAIN) { - RuleChain ruleChain = systemContext.getRuleChainService(). - findRuleChainById(tenantId, new RuleChainId(msg.getEntityId().getId())); - if (ruleChain != null && RuleChainType.CORE.equals(ruleChain.getType())) { - visit(ruleChain, target); + if (isRuleEngine) { + if (ruleChainsInitialized) { + TbActorRef target = getEntityActorRef(msg.getEntityId()); + if (target != null) { + if (msg.getEntityId().getEntityType() == EntityType.RULE_CHAIN) { + RuleChain ruleChain = systemContext.getRuleChainService(). + findRuleChainById(tenantId, new RuleChainId(msg.getEntityId().getId())); + if (ruleChain != null && RuleChainType.CORE.equals(ruleChain.getType())) { + visit(ruleChain, target); + } } + target.tellWithHighPriority(msg); + } else { + log.debug("[{}] Invalid component lifecycle msg: {}", tenantId, msg); + } + } + if (cfActor != null) { + if (msg.getEntityId().getEntityType().isOneOf(EntityType.CALCULATED_FIELD, EntityType.DEVICE, EntityType.ASSET)) { + cfActor.tellWithHighPriority(new CalculatedFieldEntityLifecycleMsg(tenantId, msg)); } - target.tellWithHighPriority(msg); - } else { - log.debug("[{}] Invalid component lifecycle msg: {}", tenantId, msg); } } } diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index cf9d6444a2..73e278389a 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -70,6 +70,7 @@ import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetInfo; import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.domain.Domain; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeInfo; @@ -80,6 +81,7 @@ import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.DeviceId; @@ -132,6 +134,7 @@ import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.audit.AuditLogService; +import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.ClaimDevicesService; @@ -367,6 +370,9 @@ public abstract class BaseController { @Autowired protected NotificationTargetService notificationTargetService; + @Autowired + protected CalculatedFieldService calculatedFieldService; + @Value("${server.log_controller_error_stack_trace}") @Getter private boolean logControllerErrorStackTrace; @@ -672,6 +678,9 @@ public abstract class BaseController { case MOBILE_APP_BUNDLE: checkMobileAppBundleId(new MobileAppBundleId(entityId.getId()), operation); return; + case CALCULATED_FIELD: + checkCalculatedFieldId(new CalculatedFieldId(entityId.getId()), operation); + return; default: checkEntityId(entityId, entitiesService::findEntityByTenantIdAndId, operation); } @@ -955,6 +964,10 @@ public abstract class BaseController { } } + protected CalculatedField checkCalculatedFieldId(CalculatedFieldId calculatedFieldId, Operation operation) throws ThingsboardException { + return checkEntityId(calculatedFieldId, calculatedFieldService::findById, operation); + } + protected HomeDashboardInfo getHomeDashboardInfo(SecurityUser securityUser, JsonNode additionalInfo) { HomeDashboardInfo homeDashboardInfo = extractHomeDashboardInfoFromAdditionalInfo(additionalInfo); if (homeDashboardInfo == null) { @@ -982,7 +995,8 @@ public abstract class BaseController { } return new HomeDashboardInfo(dashboardId, hideDashboardToolbar); } - } catch (Exception ignored) {} + } catch (Exception ignored) { + } return null; } diff --git a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java new file mode 100644 index 0000000000..f899d0f480 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java @@ -0,0 +1,283 @@ +/** + * 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. + */ +package org.thingsboard.server.controller; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.script.api.tbel.TbelCfCtx; +import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; +import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EventInfo; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.event.EventType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.HasId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.config.annotations.ApiOperation; +import org.thingsboard.server.dao.event.EventService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldScriptEngine; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldTbelScriptEngine; +import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import static org.thingsboard.server.controller.ControllerConstants.CF_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_END; +import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_START; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +@RequiredArgsConstructor +@Slf4j +public class CalculatedFieldController extends BaseController { + + private final TbCalculatedFieldService tbCalculatedFieldService; + private final EventService eventService; + private final TbelInvokeService tbelInvokeService; + + public static final String CALCULATED_FIELD_ID = "calculatedFieldId"; + + public static final int TIMEOUT = 20; + + private static final String TEST_SCRIPT_EXPRESSION = "Execute the Script expression and return the result. The format of request: \n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"expression\": \"var temp = 0; foreach(element: temperature.values) {temp += element.value;} var avgTemperature = temp / temperature.values.size(); var adjustedTemperature = avgTemperature + 0.1 * humidity.value; return {\\\"adjustedTemperature\\\": adjustedTemperature};\",\n" + + " \"arguments\": {\n" + + " \"temperature\": {\n" + + " \"type\": \"TS_ROLLING\",\n" + + " \"timeWindow\": {\n" + + " \"startTs\": 1739775630002,\n" + + " \"endTs\": 65432211,\n" + + " \"limit\": 5\n" + + " },\n" + + " \"values\": [\n" + + " { \"ts\": 1739775639851, \"value\": 23 },\n" + + " { \"ts\": 1739775664561, \"value\": 43 },\n" + + " { \"ts\": 1739775713079, \"value\": 15 },\n" + + " { \"ts\": 1739775999522, \"value\": 34 },\n" + + " { \"ts\": 1739776228452, \"value\": 22 }\n" + + " ]\n" + + " },\n" + + " \"humidity\": { \"type\": \"SINGLE_VALUE\", \"ts\": 1739776478057, \"value\": 23 }\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + "\n\n Expected result JSON contains \"output\" and \"error\"."; + + @ApiOperation(value = "Create Or Update Calculated Field (saveCalculatedField)", + notes = "Creates or Updates the Calculated Field. When creating calculated field, platform generates Calculated Field Id as " + UUID_WIKI_LINK + + "The newly created Calculated Field Id will be present in the response. " + + "Specify existing Calculated Field Id to update the calculated field. " + + "Referencing non-existing Calculated Field Id will cause 'Not Found' error. " + + "Remove 'id', 'tenantId' from the request body example (below) to create new Calculated Field entity. " + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/calculatedField", method = RequestMethod.POST) + @ResponseBody + public CalculatedField saveCalculatedField(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the calculated field.") + @RequestBody CalculatedField calculatedField) throws Exception { + calculatedField.setTenantId(getTenantId()); + checkEntity(calculatedField.getId(), calculatedField, Resource.CALCULATED_FIELD); + checkEntityId(calculatedField.getEntityId(), Operation.WRITE_CALCULATED_FIELD); + checkReferencedEntities(calculatedField.getConfiguration(), getCurrentUser()); + return tbCalculatedFieldService.save(calculatedField, getCurrentUser()); + } + + @ApiOperation(value = "Get Calculated Field (getCalculatedFieldById)", + notes = "Fetch the Calculated Field object based on the provided Calculated Field Id." + ) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/calculatedField/{calculatedFieldId}", method = RequestMethod.GET) + @ResponseBody + public CalculatedField getCalculatedFieldById(@Parameter @PathVariable(CALCULATED_FIELD_ID) String strCalculatedFieldId) throws ThingsboardException { + checkParameter(CALCULATED_FIELD_ID, strCalculatedFieldId); + CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strCalculatedFieldId)); + CalculatedField calculatedField = tbCalculatedFieldService.findById(calculatedFieldId, getCurrentUser()); + checkNotNull(calculatedField); + checkEntityId(calculatedField.getEntityId(), Operation.READ_CALCULATED_FIELD); + return calculatedField; + } + + @ApiOperation(value = "Get Calculated Fields by Entity Id (getCalculatedFieldsByEntityId)", + notes = "Fetch the Calculated Fields based on the provided Entity Id." + ) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/{entityType}/{entityId}/calculatedFields", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getCalculatedFieldsByEntityId( + @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, + @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) @RequestParam int page, + @Parameter(description = CF_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"})) @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) @RequestParam(required = false) String sortOrder) throws ThingsboardException { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + checkParameter("entityId", entityIdStr); + EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, entityIdStr); + checkEntityId(entityId, Operation.READ_CALCULATED_FIELD); + return checkNotNull(tbCalculatedFieldService.findAllByTenantIdAndEntityId(entityId, getCurrentUser(), pageLink)); + } + + @ApiOperation(value = "Delete Calculated Field (deleteCalculatedField)", + notes = "Deletes the calculated field. Referencing non-existing Calculated Field Id will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/calculatedField/{calculatedFieldId}", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteCalculatedField(@PathVariable(CALCULATED_FIELD_ID) String strCalculatedFieldId) throws Exception { + checkParameter(CALCULATED_FIELD_ID, strCalculatedFieldId); + CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strCalculatedFieldId)); + CalculatedField calculatedField = checkCalculatedFieldId(calculatedFieldId, Operation.DELETE); + checkEntityId(calculatedField.getEntityId(), Operation.WRITE_CALCULATED_FIELD); + tbCalculatedFieldService.delete(calculatedField, getCurrentUser()); + } + + @ApiOperation(value = "Get latest calculated field debug event (getLatestCalculatedFieldDebugEvent)", + notes = "Gets latest calculated field debug event for specified calculated field id. " + + "Referencing non-existing calculated field id will cause an error. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/calculatedField/{calculatedFieldId}/debug", method = RequestMethod.GET) + @ResponseBody + public JsonNode getLatestCalculatedFieldDebugEvent(@Parameter @PathVariable(CALCULATED_FIELD_ID) String strCalculatedFieldId) throws ThingsboardException { + checkParameter(CALCULATED_FIELD_ID, strCalculatedFieldId); + CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strCalculatedFieldId)); + CalculatedField calculatedField = checkCalculatedFieldId(calculatedFieldId, Operation.READ); + checkEntityId(calculatedField.getEntityId(), Operation.READ_CALCULATED_FIELD); + TenantId tenantId = getCurrentUser().getTenantId(); + return Optional.ofNullable(eventService.findLatestEvents(tenantId, calculatedFieldId, EventType.DEBUG_CALCULATED_FIELD, 1)) + .flatMap(events -> events.stream().map(EventInfo::getBody).findFirst()) + .orElse(null); + } + + @ApiOperation(value = "Test Script expression", + notes = TEST_SCRIPT_EXPRESSION + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/calculatedField/testScript", method = RequestMethod.POST) + @ResponseBody + public JsonNode testScript( + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Test calculated field TBEL expression.") + @RequestBody JsonNode inputParams) { + String expression = inputParams.get("expression").asText(); + Map arguments = Objects.requireNonNullElse( + JacksonUtil.convertValue(inputParams.get("arguments"), new TypeReference<>() { + }), + Collections.emptyMap() + ); + + ArrayList ctxAndArgNames = new ArrayList<>(arguments.size() + 1); + ctxAndArgNames.add("ctx"); + ctxAndArgNames.addAll(arguments.keySet()); + + String output = ""; + String errorText = ""; + + try { + if (tbelInvokeService == null) { + throw new IllegalArgumentException("TBEL script engine is disabled!"); + } + + CalculatedFieldScriptEngine calculatedFieldScriptEngine = new CalculatedFieldTbelScriptEngine( + getTenantId(), + tbelInvokeService, + expression, + ctxAndArgNames.toArray(String[]::new) + ); + + + Object[] args = new Object[ctxAndArgNames.size()]; + args[0] = new TbelCfCtx(arguments); + for (int i = 1; i < ctxAndArgNames.size(); i++) { + var arg = arguments.get(ctxAndArgNames.get(i)); + if (arg instanceof TbelCfSingleValueArg svArg) { + args[i] = svArg.getValue(); + } else { + args[i] = arg; + } + } + + JsonNode json = calculatedFieldScriptEngine.executeJsonAsync(args).get(TIMEOUT, TimeUnit.SECONDS); + output = JacksonUtil.toString(json); + } catch (Exception e) { + log.error("Error evaluating expression", e); + errorText = e.getMessage(); + } + + ObjectNode result = JacksonUtil.newObjectNode(); + result.put("output", output); + result.put("error", errorText); + return result; + } + + private & HasTenantId, I extends EntityId> void checkReferencedEntities(CalculatedFieldConfiguration calculatedFieldConfig, SecurityUser user) throws ThingsboardException { + List referencedEntityIds = calculatedFieldConfig.getReferencedEntities(); + for (EntityId referencedEntityId : referencedEntityIds) { + EntityType entityType = referencedEntityId.getEntityType(); + switch (entityType) { + case TENANT, CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ); + default -> + throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities."); + } + } + + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index 4987e5c26f..e45d041950 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -96,6 +96,7 @@ public class ControllerConstants { protected static final String EDGE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the edge name."; protected static final String EVENT_TEXT_SEARCH_DESCRIPTION = "The value is not used in searching."; protected static final String AUDIT_LOG_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on one of the next properties: entityType, entityName, userName, actionType, actionStatus."; + protected static final String CF_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the calculated field name."; protected static final String SORT_PROPERTY_DESCRIPTION = "Property of entity to sort by"; protected static final String SORT_ORDER_DESCRIPTION = "Sort order. ASC (ASCENDING) or DESC (DESCENDING)"; diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java b/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java index 0387e8c24a..7fd0b12077 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java @@ -20,6 +20,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; @@ -27,6 +29,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.server.common.data.edqs.ToCoreEdqsRequest; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; @@ -38,6 +41,8 @@ import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.msg.edqs.EdqsApiService; +import org.thingsboard.server.common.msg.edqs.EdqsService; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.query.EntityQueryService; @@ -55,6 +60,10 @@ public class EntityQueryController extends BaseController { @Autowired private EntityQueryService entityQueryService; + @Autowired + private EdqsService edqsService; + @Autowired + private EdqsApiService edqsApiService; private static final int MAX_PAGE_SIZE = 100; @@ -133,4 +142,16 @@ public class EntityQueryController extends BaseController { return entityQueryService.getKeysByQuery(getCurrentUser(), tenantId, query, isTimeseries, isAttributes, scope); } + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + @PostMapping("/edqs/system/request") + public void processSystemEdqsRequest(@RequestBody ToCoreEdqsRequest request) { + edqsService.processSystemRequest(request); + } + + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + @GetMapping("/edqs/enabled") + public boolean isEdqsApiEnabled() { + return edqsApiService.isEnabled(); + } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java index bc22313a03..4ee86871d6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java +++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java @@ -35,8 +35,8 @@ import org.thingsboard.server.common.data.SystemParams; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.mobile.qrCodeSettings.QrCodeSettings; import org.thingsboard.server.common.data.mobile.qrCodeSettings.QRCodeConfig; +import org.thingsboard.server.common.data.mobile.qrCodeSettings.QrCodeSettings; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.settings.UserSettings; import org.thingsboard.server.common.data.settings.UserSettingsType; @@ -46,6 +46,7 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; +import org.thingsboard.server.utils.DebugModeRateLimitsConfig; import java.util.Collections; import java.util.List; @@ -74,12 +75,6 @@ public class SystemInfoController extends BaseController { @Value("${debug.settings.default_duration:15}") private int defaultDebugDurationMinutes; - @Value("${actors.rule.chain.debug_mode_rate_limits_per_tenant.enabled:true}") - private boolean ruleChainDebugPerTenantLimitsEnabled; - - @Value("${actors.rule.chain.debug_mode_rate_limits_per_tenant.configuration:50000:3600}") - private String ruleChainDebugPerTenantLimitsConfiguration; - @Autowired(required = false) private BuildProperties buildProperties; @@ -89,6 +84,9 @@ public class SystemInfoController extends BaseController { @Autowired private QrCodeSettingService qrCodeSettingService; + @Autowired + private DebugModeRateLimitsConfig debugModeRateLimitsConfig; + @PostConstruct public void init() { JsonNode info = buildInfoObject(); @@ -152,9 +150,14 @@ public class SystemInfoController extends BaseController { DefaultTenantProfileConfiguration tenantProfileConfiguration = tenantProfileCache.get(tenantId).getDefaultProfileConfiguration(); systemParams.setMaxResourceSize(tenantProfileConfiguration.getMaxResourceSize()); systemParams.setMaxDebugModeDurationMinutes(DebugModeUtil.getMaxDebugAllDuration(tenantProfileConfiguration.getMaxDebugModeDurationMinutes(), defaultDebugDurationMinutes)); - if (ruleChainDebugPerTenantLimitsEnabled) { - systemParams.setRuleChainDebugPerTenantLimitsConfiguration(ruleChainDebugPerTenantLimitsConfiguration); + if (debugModeRateLimitsConfig.isRuleChainDebugPerTenantLimitsEnabled()) { + systemParams.setRuleChainDebugPerTenantLimitsConfiguration(debugModeRateLimitsConfig.getRuleChainDebugPerTenantLimitsConfiguration()); + } + if (debugModeRateLimitsConfig.isCalculatedFieldDebugPerTenantLimitsEnabled()) { + systemParams.setCalculatedFieldDebugPerTenantLimitsConfiguration(debugModeRateLimitsConfig.getCalculatedFieldDebugPerTenantLimitsConfiguration()); } + systemParams.setMaxArgumentsPerCF(tenantProfileConfiguration.getMaxArgumentsPerCF()); + systemParams.setMaxDataPointsPerRollingArg(tenantProfileConfiguration.getMaxDataPointsPerRollingArg()); } systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID)) .map(QrCodeSettings::getQrCodeConfig).map(QRCodeConfig::isShowOnHomePage) diff --git a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java index ff4de0ce0c..1a3bdce1f6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java @@ -18,6 +18,7 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -160,7 +161,12 @@ public class TenantProfileController extends BaseController { " \"rpcTtlDays\": 0,\n" + " \"queueStatsTtlDays\": 0,\n" + " \"ruleEngineExceptionsTtlDays\": 0,\n" + - " \"warnThreshold\": 0\n" + + " \"warnThreshold\": 0,\n" + + " \"maxCalculatedFieldsPerEntity\": 5,\n" + + " \"maxArgumentsPerCF\": 10,\n" + + " \"maxDataPointsPerRollingArg\": 1000,\n" + + " \"maxStateSizeInKBytes\": 32,\n" + + " \"maxSingleValueArgumentSizeInKBytes\": 2" + " }\n" + " },\n" + " \"default\": false\n" + @@ -172,7 +178,7 @@ public class TenantProfileController extends BaseController { @RequestMapping(value = "/tenantProfile", method = RequestMethod.POST) @ResponseBody public TenantProfile saveTenantProfile(@Parameter(description = "A JSON value representing the tenant profile.") - @RequestBody TenantProfile tenantProfile) throws ThingsboardException { + @Valid @RequestBody TenantProfile tenantProfile) throws ThingsboardException { TenantProfile oldProfile; if (tenantProfile.getId() == null) { accessControlService.checkPermission(getCurrentUser(), Resource.TENANT_PROFILE, Operation.CREATE); diff --git a/application/src/main/java/org/thingsboard/server/exception/CalculatedFieldStateException.java b/application/src/main/java/org/thingsboard/server/exception/CalculatedFieldStateException.java new file mode 100644 index 0000000000..a30b7218a1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/exception/CalculatedFieldStateException.java @@ -0,0 +1,24 @@ +/** + * 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. + */ +package org.thingsboard.server.exception; + +public class CalculatedFieldStateException extends RuntimeException { + + public CalculatedFieldStateException(String message) { + super(message); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java b/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java index aecb31ca0c..3dedea999c 100644 --- a/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java @@ -15,13 +15,11 @@ */ package org.thingsboard.server.service.apiusage; -import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.ListenableFuture; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.checkerframework.checker.nullness.qual.Nullable; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; @@ -56,10 +54,12 @@ import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.common.msg.tools.SchedulerUtils; +import org.thingsboard.server.common.util.ProtoUtils; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.dao.usagerecord.ApiUsageStateService; +import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ToUsageStatsServiceMsg; import org.thingsboard.server.gen.transport.TransportProtos.UsageStatsKVProto; import org.thingsboard.server.queue.common.TbProtoQueueMsg; @@ -92,15 +92,7 @@ import java.util.stream.Collectors; public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService implements TbApiUsageStateService { public static final String HOURLY = "Hourly"; - public static final FutureCallback VOID_CALLBACK = new FutureCallback() { - @Override - public void onSuccess(@Nullable Void result) { - } - @Override - public void onFailure(Throwable t) { - } - }; private final PartitionService partitionService; private final TenantService tenantService; private final TimeseriesService tsService; @@ -154,18 +146,40 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService } @Override - public void process(TbProtoQueueMsg msg, TbCallback callback) { - ToUsageStatsServiceMsg statsMsg = msg.getValue(); - - TenantId tenantId = TenantId.fromUUID(new UUID(statsMsg.getTenantIdMSB(), statsMsg.getTenantIdLSB())); - EntityId ownerId; - if (statsMsg.getCustomerIdMSB() != 0 && statsMsg.getCustomerIdLSB() != 0) { - ownerId = new CustomerId(new UUID(statsMsg.getCustomerIdMSB(), statsMsg.getCustomerIdLSB())); + public void process(TbProtoQueueMsg msgPack, TbCallback callback) { + ToUsageStatsServiceMsg serviceMsg = msgPack.getValue(); + String serviceId = serviceMsg.getServiceId(); + + List msgs; + + //For backward compatibility, remove after release + if (serviceMsg.getMsgsList().isEmpty()) { + TransportProtos.UsageStatsServiceMsg oldMsg = TransportProtos.UsageStatsServiceMsg.newBuilder() + .setTenantIdMSB(serviceMsg.getTenantIdMSB()) + .setTenantIdLSB(serviceMsg.getTenantIdLSB()) + .setCustomerIdMSB(serviceMsg.getCustomerIdMSB()) + .setCustomerIdLSB(serviceMsg.getCustomerIdLSB()) + .setEntityIdMSB(serviceMsg.getEntityIdMSB()) + .setEntityIdLSB(serviceMsg.getEntityIdLSB()) + .addAllValues(serviceMsg.getValuesList()) + .build(); + + msgs = List.of(oldMsg); } else { - ownerId = tenantId; + msgs = serviceMsg.getMsgsList(); } - processEntityUsageStats(tenantId, ownerId, statsMsg.getValuesList(), statsMsg.getServiceId()); + msgs.forEach(msg -> { + TenantId tenantId = TenantId.fromUUID(new UUID(msg.getTenantIdMSB(), msg.getTenantIdLSB())); + EntityId ownerId; + if (msg.getCustomerIdMSB() != 0 && msg.getCustomerIdLSB() != 0) { + ownerId = new CustomerId(new UUID(msg.getCustomerIdMSB(), msg.getCustomerIdLSB())); + } else { + ownerId = tenantId; + } + + processEntityUsageStats(tenantId, ownerId, msg.getValuesList(), serviceId); + }); callback.onSuccess(); } @@ -191,7 +205,14 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService updatedEntries = new ArrayList<>(ApiUsageRecordKey.values().length); Set apiFeatures = new HashSet<>(); for (UsageStatsKVProto statsItem : values) { - ApiUsageRecordKey recordKey = ApiUsageRecordKey.valueOf(statsItem.getKey()); + ApiUsageRecordKey recordKey; + + //For backward compatibility, remove after release + if (StringUtils.isNotEmpty(statsItem.getKey())) { + recordKey = ApiUsageRecordKey.valueOf(statsItem.getKey()); + } else { + recordKey = ProtoUtils.fromProto(statsItem.getRecordKey()); + } StatsCalculationResult calculationResult = usageState.calculate(recordKey, statsItem.getValue(), serviceId); if (calculationResult.isValueChanged()) { @@ -219,7 +240,6 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService .tenantId(tenantId) .entityId(usageState.getApiUsageState().getId()) .entries(updatedEntries) - .callback(VOID_CALLBACK) .build()); if (!result.isEmpty()) { persistAndNotify(usageState, result); @@ -331,7 +351,6 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService .tenantId(tenantId) .entityId(id) .entries(profileThresholds) - .callback(VOID_CALLBACK) .build()); } } @@ -364,7 +383,6 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService .tenantId(state.getTenantId()) .entityId(state.getApiUsageState().getId()) .entries(stateTelemetry) - .callback(VOID_CALLBACK) .build()); if (state.getEntityType() == EntityType.TENANT && !state.getEntityId().equals(TenantId.SYS_TENANT_ID)) { @@ -457,7 +475,6 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService .tenantId(state.getTenantId()) .entityId(state.getApiUsageState().getId()) .entries(counts) - .callback(VOID_CALLBACK) .build()); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldStateService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldStateService.java new file mode 100644 index 0000000000..91c08ab6e0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldStateService.java @@ -0,0 +1,93 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf; + +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.calculatedField.CalculatedFieldStateRestoreMsg; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.exception.CalculatedFieldStateException; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.state.QueueStateService; +import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; + +import java.util.Collection; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.thingsboard.server.utils.CalculatedFieldUtils.fromProto; +import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto; + +public abstract class AbstractCalculatedFieldStateService implements CalculatedFieldStateService { + + @Autowired + private ActorSystemContext actorSystemContext; + + protected QueueStateService, TbProtoQueueMsg> stateService; + + @Override + public final void persistState(CalculatedFieldEntityCtxId stateId, CalculatedFieldState state, TbCallback callback) { + if (state.isSizeExceedsLimit()) { + throw new CalculatedFieldStateException("State size exceeds the maximum allowed limit. The state will not be persisted to RocksDB."); + } + doPersist(stateId, toProto(stateId, state), callback); + } + + protected abstract void doPersist(CalculatedFieldEntityCtxId stateId, CalculatedFieldStateProto stateMsgProto, TbCallback callback); + + @Override + public final void removeState(CalculatedFieldEntityCtxId stateId, TbCallback callback) { + doRemove(stateId, callback); + } + + protected abstract void doRemove(CalculatedFieldEntityCtxId stateId, TbCallback callback); + + protected void processRestoredState(CalculatedFieldStateProto stateMsg) { + var id = fromProto(stateMsg.getId()); + var state = fromProto(stateMsg); + processRestoredState(id, state); + } + + protected void processRestoredState(CalculatedFieldEntityCtxId id, CalculatedFieldState state) { + actorSystemContext.tell(new CalculatedFieldStateRestoreMsg(id, state)); + } + + @Override + public void restore(QueueKey queueKey, Set partitions) { + stateService.update(queueKey, partitions); + } + + @Override + public void delete(Set partitions) { + stateService.delete(partitions); + } + + @Override + public Set getPartitions() { + return stateService.getPartitions().values().stream().flatMap(Collection::stream).collect(Collectors.toSet()); + } + + @Override + public void stop() { + stateService.stop(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java new file mode 100644 index 0000000000..fb63432fed --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java @@ -0,0 +1,45 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf; + +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; + +import java.util.List; + +public interface CalculatedFieldCache { + + CalculatedField getCalculatedField(CalculatedFieldId calculatedFieldId); + + List getCalculatedFieldsByEntityId(EntityId entityId); + + List getCalculatedFieldLinksByEntityId(EntityId entityId); + + CalculatedFieldCtx getCalculatedFieldCtx(CalculatedFieldId calculatedFieldId); + + List getCalculatedFieldCtxsByEntityId(EntityId entityId); + + void addCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId); + + void updateCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId); + + void evict(CalculatedFieldId calculatedFieldId); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldInitService.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldInitService.java new file mode 100644 index 0000000000..6505dae581 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldInitService.java @@ -0,0 +1,19 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf; + +public interface CalculatedFieldInitService { +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java new file mode 100644 index 0000000000..847caccaff --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java @@ -0,0 +1,43 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.actors.calculatedField.CalculatedFieldTelemetryMsg; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; + +import java.util.List; +import java.util.Map; + +public interface CalculatedFieldProcessingService { + + ListenableFuture fetchStateFromDb(CalculatedFieldCtx ctx, EntityId entityId); + + Map fetchArgsFromDb(TenantId tenantId, EntityId entityId, Map arguments); + + void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult calculationResult, List cfIds, TbCallback callback); + + void pushMsgToLinks(CalculatedFieldTelemetryMsg msg, List linkedCalculatedFields, TbCallback callback); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldQueueService.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldQueueService.java new file mode 100644 index 0000000000..eb86220361 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldQueueService.java @@ -0,0 +1,44 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf; + +import com.google.common.util.concurrent.FutureCallback; +import org.thingsboard.rule.engine.api.AttributesDeleteRequest; +import org.thingsboard.rule.engine.api.AttributesSaveRequest; +import org.thingsboard.rule.engine.api.RuleEngineCalculatedFieldQueueService; +import org.thingsboard.rule.engine.api.TimeseriesDeleteRequest; +import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; +import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; + +import java.util.List; + +public interface CalculatedFieldQueueService extends RuleEngineCalculatedFieldQueueService { + + /** + * Filter CFs based on the request entity. Push to the queue if any matching CF exist; + * + * @param request - telemetry save request; + * @param callback + */ + void pushRequestToQueue(TimeseriesSaveRequest request, TimeseriesSaveResult result, FutureCallback callback); + + void pushRequestToQueue(AttributesSaveRequest request, List result, FutureCallback callback); + + void pushRequestToQueue(AttributesDeleteRequest request, List result, FutureCallback callback); + + void pushRequestToQueue(TimeseriesDeleteRequest request, List result, FutureCallback callback); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java new file mode 100644 index 0000000000..49acf6917c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java @@ -0,0 +1,37 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.cf.configuration.OutputType; + +@Data +public final class CalculatedFieldResult { + + private final OutputType type; + private final AttributeScope scope; + private final JsonNode result; + + public boolean isEmpty() { + return result == null || result.isMissingNode() || result.isNull() || + (result.isObject() && result.isEmpty()) || + (result.isArray() && result.isEmpty()) || + (result.isTextual() && result.asText().isEmpty()); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldStateService.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldStateService.java new file mode 100644 index 0000000000..d0b34f18e8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldStateService.java @@ -0,0 +1,46 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf; + +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.exception.CalculatedFieldStateException; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; +import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; + +import java.util.Set; + +public interface CalculatedFieldStateService { + + void init(PartitionedQueueConsumerManager> eventConsumer); + + void persistState(CalculatedFieldEntityCtxId stateId, CalculatedFieldState state, TbCallback callback) throws CalculatedFieldStateException; + + void removeState(CalculatedFieldEntityCtxId stateId, TbCallback callback); + + void restore(QueueKey queueKey, Set partitions); + + void delete(Set partitions); + + Set getPartitions(); + + void stop(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CfRocksDb.java b/application/src/main/java/org/thingsboard/server/service/cf/CfRocksDb.java new file mode 100644 index 0000000000..f95227bc24 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/CfRocksDb.java @@ -0,0 +1,47 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.rocksdb.Options; +import org.rocksdb.WriteOptions; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Component; +import org.thingsboard.server.edqs.util.TbRocksDb; + +@Component +@ConditionalOnExpression("'${queue.type:null}'=='in-memory'") +public class CfRocksDb extends TbRocksDb { + + public CfRocksDb(@Value("${queue.calculated_fields.rocks_db_path:${user.home}/.rocksdb/cf_states}") String path) { + super(path, new Options().setCreateIfMissing(true), new WriteOptions().setSync(true)); + } + + @PostConstruct + @Override + public void init() { + super.init(); + } + + @PreDestroy + @Override + public void close() { + super.close(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java new file mode 100644 index 0000000000..64487d9b3e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -0,0 +1,187 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageDataIterable; +import org.thingsboard.server.common.msg.cf.CalculatedFieldInitMsg; +import org.thingsboard.server.common.msg.cf.CalculatedFieldLinkInitMsg; +import org.thingsboard.server.dao.cf.CalculatedFieldService; +import org.thingsboard.server.dao.usagerecord.ApiLimitService; +import org.thingsboard.server.queue.util.AfterStartUp; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +@Service +@Slf4j +@RequiredArgsConstructor +public class DefaultCalculatedFieldCache implements CalculatedFieldCache { + + private static final Integer UNKNOWN_PARTITION = -1; + + private final Lock calculatedFieldFetchLock = new ReentrantLock(); + + private final CalculatedFieldService calculatedFieldService; + private final TbelInvokeService tbelInvokeService; + private final ActorSystemContext actorSystemContext; + private final ApiLimitService apiLimitService; + + private final ConcurrentMap calculatedFields = new ConcurrentHashMap<>(); + private final ConcurrentMap> entityIdCalculatedFields = new ConcurrentHashMap<>(); + private final ConcurrentMap> calculatedFieldLinks = new ConcurrentHashMap<>(); + private final ConcurrentMap> entityIdCalculatedFieldLinks = new ConcurrentHashMap<>(); + private final ConcurrentMap calculatedFieldsCtx = new ConcurrentHashMap<>(); + + @Value("${calculatedField.initFetchPackSize:50000}") + @Getter + private int initFetchPackSize; + + @AfterStartUp(order = AfterStartUp.CF_READ_CF_SERVICE) + public void init() { + //TODO: move to separate place to avoid circular references with the ActorSystemContext (@Lazy for tsSubService) + PageDataIterable cfs = new PageDataIterable<>(calculatedFieldService::findAllCalculatedFields, initFetchPackSize); + cfs.forEach(cf -> { + calculatedFields.putIfAbsent(cf.getId(), cf); + actorSystemContext.tell(new CalculatedFieldInitMsg(cf.getTenantId(), cf)); + }); + calculatedFields.values().forEach(cf -> { + entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cf); + }); + PageDataIterable cfls = new PageDataIterable<>(calculatedFieldService::findAllCalculatedFieldLinks, initFetchPackSize); + cfls.forEach(link -> { + calculatedFieldLinks.computeIfAbsent(link.getCalculatedFieldId(), id -> new CopyOnWriteArrayList<>()).add(link); + actorSystemContext.tell(new CalculatedFieldLinkInitMsg(link.getTenantId(), link)); + }); + calculatedFieldLinks.values().stream() + .flatMap(List::stream) + .forEach(link -> + entityIdCalculatedFieldLinks.computeIfAbsent(link.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(link) + ); + } + + @Override + public CalculatedField getCalculatedField(CalculatedFieldId calculatedFieldId) { + return calculatedFields.get(calculatedFieldId); + } + + @Override + public List getCalculatedFieldsByEntityId(EntityId entityId) { + return entityIdCalculatedFields.getOrDefault(entityId, new CopyOnWriteArrayList<>()); + } + + @Override + public List getCalculatedFieldLinksByEntityId(EntityId entityId) { + return entityIdCalculatedFieldLinks.getOrDefault(entityId, new CopyOnWriteArrayList<>()); + } + + @Override + public CalculatedFieldCtx getCalculatedFieldCtx(CalculatedFieldId calculatedFieldId) { + CalculatedFieldCtx ctx = calculatedFieldsCtx.get(calculatedFieldId); + if (ctx == null) { + calculatedFieldFetchLock.lock(); + try { + ctx = calculatedFieldsCtx.get(calculatedFieldId); + if (ctx == null) { + CalculatedField calculatedField = getCalculatedField(calculatedFieldId); + if (calculatedField != null) { + ctx = new CalculatedFieldCtx(calculatedField, tbelInvokeService, apiLimitService); + calculatedFieldsCtx.put(calculatedFieldId, ctx); + log.debug("[{}] Put calculated field ctx into cache: {}", calculatedFieldId, ctx); + } + } + } finally { + calculatedFieldFetchLock.unlock(); + } + } + log.trace("[{}] Found calculated field ctx in cache: {}", calculatedFieldId, ctx); + return ctx; + } + + @Override + public List getCalculatedFieldCtxsByEntityId(EntityId entityId) { + if (entityId == null) { + return Collections.emptyList(); + } + return getCalculatedFieldsByEntityId(entityId).stream() + .map(cf -> getCalculatedFieldCtx(cf.getId())) + .toList(); + } + + @Override + public void addCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId) { + calculatedFieldFetchLock.lock(); + try { + CalculatedField calculatedField = calculatedFieldService.findById(tenantId, calculatedFieldId); + EntityId cfEntityId = calculatedField.getEntityId(); + + calculatedFields.put(calculatedFieldId, calculatedField); + + entityIdCalculatedFields.computeIfAbsent(cfEntityId, entityId -> new CopyOnWriteArrayList<>()).add(calculatedField); + + CalculatedFieldConfiguration configuration = calculatedField.getConfiguration(); + calculatedFieldLinks.put(calculatedFieldId, configuration.buildCalculatedFieldLinks(tenantId, cfEntityId, calculatedFieldId)); + + configuration.getReferencedEntities().stream() + .filter(referencedEntityId -> !referencedEntityId.equals(cfEntityId)) + .forEach(referencedEntityId -> { + entityIdCalculatedFieldLinks.computeIfAbsent(referencedEntityId, entityId -> new CopyOnWriteArrayList<>()) + .add(configuration.buildCalculatedFieldLink(tenantId, referencedEntityId, calculatedFieldId)); + }); + } finally { + calculatedFieldFetchLock.unlock(); + } + } + + @Override + public void updateCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId) { + evict(calculatedFieldId); + addCalculatedField(tenantId, calculatedFieldId); + } + + @Override + public void evict(CalculatedFieldId calculatedFieldId) { + CalculatedField oldCalculatedField = calculatedFields.remove(calculatedFieldId); + log.debug("[{}] evict calculated field from cache: {}", calculatedFieldId, oldCalculatedField); + calculatedFieldLinks.remove(calculatedFieldId); + log.debug("[{}] evict calculated field from cached calculated fields by entity id: {}", calculatedFieldId, oldCalculatedField); + entityIdCalculatedFields.forEach((entityId, calculatedFields) -> calculatedFields.removeIf(cf -> cf.getId().equals(calculatedFieldId))); + log.debug("[{}] evict calculated field links from cache: {}", calculatedFieldId, oldCalculatedField); + calculatedFieldsCtx.remove(calculatedFieldId); + log.debug("[{}] evict calculated field ctx from cache: {}", calculatedFieldId, oldCalculatedField); + entityIdCalculatedFieldLinks.forEach((entityId, calculatedFieldLinks) -> calculatedFieldLinks.removeIf(link -> link.getCalculatedFieldId().equals(calculatedFieldId))); + log.debug("[{}] evict calculated field links from cached links by entity id: {}", calculatedFieldId, oldCalculatedField); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldInitService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldInitService.java new file mode 100644 index 0000000000..cc3022fa29 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldInitService.java @@ -0,0 +1,67 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.ProfileEntityIdInfo; +import org.thingsboard.server.common.data.page.PageDataIterable; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.queue.util.AfterStartUp; +import org.thingsboard.server.queue.util.TbRuleEngineComponent; +import org.thingsboard.server.service.cf.cache.CalculatedFieldEntityProfileCache; + +@Slf4j +@Service +@TbRuleEngineComponent +@RequiredArgsConstructor +public class DefaultCalculatedFieldInitService implements CalculatedFieldInitService { + + private final CalculatedFieldEntityProfileCache entityProfileCache; + private final AssetService assetService; + private final DeviceService deviceService; + + @Value("${calculated_fields.init_fetch_pack_size:50000}") + @Getter + private int initFetchPackSize; + + @AfterStartUp(order = AfterStartUp.CF_READ_PROFILE_ENTITIES_SERVICE) + public void initCalculatedFieldDefinitions() { + PageDataIterable deviceIdInfos = new PageDataIterable<>(deviceService::findProfileEntityIdInfos, initFetchPackSize); + for (ProfileEntityIdInfo idInfo : deviceIdInfos) { + log.trace("Processing device record: {}", idInfo); + try { + entityProfileCache.add(idInfo.getTenantId(), idInfo.getProfileId(), idInfo.getEntityId()); + } catch (Exception e) { + log.error("Failed to process device record: {}", idInfo, e); + } + } + PageDataIterable assetIdInfos = new PageDataIterable<>(assetService::findProfileEntityIdInfos, initFetchPackSize); + for (ProfileEntityIdInfo idInfo : assetIdInfos) { + log.trace("Processing asset record: {}", idInfo); + try { + entityProfileCache.add(idInfo.getTenantId(), idInfo.getProfileId(), idInfo.getEntityId()); + } catch (Exception e) { + log.error("Failed to process asset record: {}", idInfo, e); + } + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java new file mode 100644 index 0000000000..e9a6cb09aa --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java @@ -0,0 +1,327 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.math.NumberUtils; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.actors.calculatedField.CalculatedFieldTelemetryMsg; +import org.thingsboard.server.actors.calculatedField.MultipleTbCallback; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.Aggregation; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.ReadTsKvQuery; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.dao.usagerecord.ApiLimitService; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldLinkedTelemetryMsgProto; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldLinkedTelemetryMsgProto.Builder; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueMsgMetadata; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.queue.util.TbRuleEngineComponent; +import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.TsRollingArgumentEntry; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +import static org.thingsboard.server.common.data.DataConstants.SCOPE; +import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto; + +@TbRuleEngineComponent +@Service +@Slf4j +@RequiredArgsConstructor +public class DefaultCalculatedFieldProcessingService implements CalculatedFieldProcessingService { + + private final AttributesService attributesService; + private final TimeseriesService timeseriesService; + private final TbClusterService clusterService; + private final ApiLimitService apiLimitService; + private final PartitionService partitionService; + + private ListeningExecutorService calculatedFieldCallbackExecutor; + + @PostConstruct + public void init() { + calculatedFieldCallbackExecutor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool( + Math.max(4, Runtime.getRuntime().availableProcessors()), "calculated-field-callback")); + } + + @PreDestroy + public void stop() { + if (calculatedFieldCallbackExecutor != null) { + calculatedFieldCallbackExecutor.shutdownNow(); + } + } + + @Override + public ListenableFuture fetchStateFromDb(CalculatedFieldCtx ctx, EntityId entityId) { + Map> argFutures = new HashMap<>(); + for (var entry : ctx.getArguments().entrySet()) { + var argEntityId = entry.getValue().getRefEntityId() != null ? entry.getValue().getRefEntityId() : entityId; + var argValueFuture = fetchKvEntry(ctx.getTenantId(), argEntityId, entry.getValue()); + argFutures.put(entry.getKey(), argValueFuture); + } + return Futures.whenAllComplete(argFutures.values()).call(() -> { + var result = createStateByType(ctx); + result.updateState(ctx, argFutures.entrySet().stream() + .collect(Collectors.toMap( + Entry::getKey, // Keep the key as is + entry -> { + try { + // Resolve the future to get the value + return entry.getValue().get(); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException("Error getting future result for key: " + entry.getKey(), e); + } + } + ))); + return result; + }, calculatedFieldCallbackExecutor); + } + + @Override + public Map fetchArgsFromDb(TenantId tenantId, EntityId entityId, Map arguments) { + Map> argFutures = new HashMap<>(); + for (var entry : arguments.entrySet()) { + var argEntityId = entry.getValue().getRefEntityId() != null ? entry.getValue().getRefEntityId() : entityId; + var argValueFuture = fetchKvEntry(tenantId, argEntityId, entry.getValue()); + argFutures.put(entry.getKey(), argValueFuture); + } + return argFutures.entrySet().stream() + .collect(Collectors.toMap( + Entry::getKey, // Keep the key as is + entry -> { + try { + // Resolve the future to get the value + return entry.getValue().get(); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException("Error getting future result for key: " + entry.getKey(), e); + } + } + )); + } + + @Override + public void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult calculatedFieldResult, List cfIds, TbCallback callback) { + try { + OutputType type = calculatedFieldResult.getType(); + TbMsgType msgType = OutputType.ATTRIBUTES.equals(type) ? TbMsgType.POST_ATTRIBUTES_REQUEST : TbMsgType.POST_TELEMETRY_REQUEST; + TbMsgMetaData md = OutputType.ATTRIBUTES.equals(type) ? new TbMsgMetaData(Map.of(SCOPE, calculatedFieldResult.getScope().name())) : TbMsgMetaData.EMPTY; + TbMsg msg = TbMsg.newMsg().type(msgType).originator(entityId).previousCalculatedFieldIds(cfIds).metaData(md).data(JacksonUtil.writeValueAsString(calculatedFieldResult.getResult())).build(); + clusterService.pushMsgToRuleEngine(tenantId, entityId, msg, new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + callback.onSuccess(); + log.trace("[{}][{}] Pushed message to rule engine: {} ", tenantId, entityId, msg); + } + + @Override + public void onFailure(Throwable t) { + callback.onFailure(t); + } + }); + } catch (Exception e) { + log.warn("[{}][{}] Failed to push message to rule engine. CalculatedFieldResult: {}", tenantId, entityId, calculatedFieldResult, e); + callback.onFailure(e); + } + } + + @Override + public void pushMsgToLinks(CalculatedFieldTelemetryMsg msg, List linkedCalculatedFields, TbCallback callback) { + Map> unicasts = new HashMap<>(); + List broadcasts = new ArrayList<>(); + for (CalculatedFieldEntityCtxId link : linkedCalculatedFields) { + var linkEntityId = link.entityId(); + var linkEntityType = linkEntityId.getEntityType(); + // Let's assume number of entities in profile is N, and number of partitions is P. If N > P, we save by broadcasting to all partitions. Usually N >> P. + boolean broadcast = EntityType.DEVICE_PROFILE.equals(linkEntityType) || EntityType.ASSET_PROFILE.equals(linkEntityType); + if (broadcast) { + broadcasts.add(link); + } else { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_RULE_ENGINE, DataConstants.CF_QUEUE_NAME, link.tenantId(), link.entityId()); + unicasts.computeIfAbsent(tpi, k -> new ArrayList<>()).add(link); + } + } + MultipleTbCallback linkCallback = new MultipleTbCallback(2, callback); + if (!broadcasts.isEmpty()) { + broadcast(broadcasts, msg, linkCallback); + } else { + linkCallback.onSuccess(); + } + if (!unicasts.isEmpty()) { + unicast(unicasts, msg, linkCallback); + } else { + linkCallback.onSuccess(); + } + } + + private void unicast(Map> unicasts, CalculatedFieldTelemetryMsg msg, MultipleTbCallback mainCallback) { + TbQueueCallback callback = new TbCallbackWrapper(new MultipleTbCallback(unicasts.size(), mainCallback)); + unicasts.forEach((topicPartitionInfo, ctxIds) -> { + CalculatedFieldLinkedTelemetryMsgProto linkedTelemetryMsgProto = buildLinkedTelemetryMsgProto(msg.getProto(), ctxIds); + clusterService.pushMsgToCalculatedFields(topicPartitionInfo, UUID.randomUUID(), + ToCalculatedFieldMsg.newBuilder().setLinkedTelemetryMsg(linkedTelemetryMsgProto).build(), callback); + }); + } + + private void broadcast(List broadcasts, CalculatedFieldTelemetryMsg msg, MultipleTbCallback mainCallback) { + TbQueueCallback callback = new TbCallbackWrapper(mainCallback); + CalculatedFieldLinkedTelemetryMsgProto linkedTelemetryMsgProto = buildLinkedTelemetryMsgProto(msg.getProto(), broadcasts); + clusterService.broadcastToCalculatedFields(ToCalculatedFieldNotificationMsg.newBuilder().setLinkedTelemetryMsg(linkedTelemetryMsgProto).build(), callback); + } + + private CalculatedFieldLinkedTelemetryMsgProto buildLinkedTelemetryMsgProto(CalculatedFieldTelemetryMsgProto telemetryProto, List links) { + Builder builder = CalculatedFieldLinkedTelemetryMsgProto.newBuilder(); + builder.setMsg(telemetryProto); + for (CalculatedFieldEntityCtxId link : links) { + builder.addLinks(toProto(link)); + } + return builder.build(); + } + + private ListenableFuture fetchKvEntry(TenantId tenantId, EntityId entityId, Argument argument) { + return switch (argument.getRefEntityKey().getType()) { + case TS_ROLLING -> fetchTsRolling(tenantId, entityId, argument); + case ATTRIBUTE -> transformSingleValueArgument( + Futures.transform( + attributesService.find(tenantId, entityId, argument.getRefEntityKey().getScope(), argument.getRefEntityKey().getKey()), + result -> result.or(() -> Optional.of(new BaseAttributeKvEntry(createDefaultKvEntry(argument), System.currentTimeMillis(), 0L))), + calculatedFieldCallbackExecutor) + ); + case TS_LATEST -> transformSingleValueArgument( + Futures.transform( + timeseriesService.findLatest(tenantId, entityId, argument.getRefEntityKey().getKey()), + result -> result.or(() -> Optional.of(new BasicTsKvEntry(System.currentTimeMillis(), createDefaultKvEntry(argument), 0L))), + calculatedFieldCallbackExecutor)); + }; + } + + private ListenableFuture transformSingleValueArgument(ListenableFuture> kvEntryFuture) { + return Futures.transform(kvEntryFuture, kvEntry -> { + if (kvEntry.isPresent() && kvEntry.get().getValue() != null) { + return ArgumentEntry.createSingleValueArgument(kvEntry.get()); + } else { + return new SingleValueArgumentEntry(); + } + }, calculatedFieldCallbackExecutor); + } + + private ListenableFuture fetchTsRolling(TenantId tenantId, EntityId entityId, Argument argument) { + long currentTime = System.currentTimeMillis(); + long timeWindow = argument.getTimeWindow() == 0 ? System.currentTimeMillis() : argument.getTimeWindow(); + long startTs = currentTime - timeWindow; + long maxDataPoints = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg); + int argumentLimit = argument.getLimit(); + int limit = argumentLimit == 0 || argumentLimit > maxDataPoints ? (int) maxDataPoints : argument.getLimit(); + + ReadTsKvQuery query = new BaseReadTsKvQuery(argument.getRefEntityKey().getKey(), startTs, currentTime, 0, limit, Aggregation.NONE); + ListenableFuture> tsRollingFuture = timeseriesService.findAll(tenantId, entityId, List.of(query)); + + return Futures.transform(tsRollingFuture, tsRolling -> tsRolling == null ? new TsRollingArgumentEntry(limit, timeWindow) : ArgumentEntry.createTsRollingArgument(tsRolling, limit, timeWindow), calculatedFieldCallbackExecutor); + } + + private KvEntry createDefaultKvEntry(Argument argument) { + String key = argument.getRefEntityKey().getKey(); + String defaultValue = argument.getDefaultValue(); + if (StringUtils.isBlank(defaultValue)) { + return new StringDataEntry(key, null); + } + if (NumberUtils.isParsable(defaultValue)) { + return new DoubleDataEntry(key, Double.parseDouble(defaultValue)); + } + if ("true".equalsIgnoreCase(defaultValue) || "false".equalsIgnoreCase(defaultValue)) { + return new BooleanDataEntry(key, Boolean.parseBoolean(defaultValue)); + } + return new StringDataEntry(key, defaultValue); + } + + private CalculatedFieldState createStateByType(CalculatedFieldCtx ctx) { + return switch (ctx.getCfType()) { + case SIMPLE -> new SimpleCalculatedFieldState(ctx.getArgNames()); + case SCRIPT -> new ScriptCalculatedFieldState(ctx.getArgNames()); + }; + } + + private static class TbCallbackWrapper implements TbQueueCallback { + private final TbCallback callback; + + public TbCallbackWrapper(TbCallback callback) { + this.callback = callback; + } + + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + callback.onSuccess(); + } + + @Override + public void onFailure(Throwable t) { + callback.onFailure(t); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java new file mode 100644 index 0000000000..8289e4db42 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java @@ -0,0 +1,282 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf; + +import com.google.common.util.concurrent.FutureCallback; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.rule.engine.api.AttributesDeleteRequest; +import org.thingsboard.rule.engine.api.AttributesSaveRequest; +import org.thingsboard.rule.engine.api.TimeseriesDeleteRequest; +import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.util.ProtoUtils; +import org.thingsboard.server.gen.transport.TransportProtos.AttributeScopeProto; +import org.thingsboard.server.gen.transport.TransportProtos.AttributeValueProto; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueMsgMetadata; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; +import org.thingsboard.server.service.profile.TbAssetProfileCache; +import org.thingsboard.server.service.profile.TbDeviceProfileCache; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import static org.thingsboard.server.common.util.ProtoUtils.toTsKvProto; +import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto; + +@Service +@Slf4j +@RequiredArgsConstructor +public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueService { + + public static final TbQueueCallback DUMMY_TB_QUEUE_CALLBACK = new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + } + + @Override + public void onFailure(Throwable t) { + } + }; + + private final TbAssetProfileCache assetProfileCache; + private final TbDeviceProfileCache deviceProfileCache; + private final CalculatedFieldCache calculatedFieldCache; + private final TbClusterService clusterService; + + private static final Set supportedReferencedEntities = EnumSet.of( + EntityType.DEVICE, EntityType.ASSET, EntityType.CUSTOMER, EntityType.TENANT + ); + + @Override + public void pushRequestToQueue(TimeseriesSaveRequest request, TimeseriesSaveResult result, FutureCallback callback) { + var tenantId = request.getTenantId(); + var entityId = request.getEntityId(); + checkEntityAndPushToQueue(tenantId, entityId, cf -> cf.matches(request.getEntries()), cf -> cf.linkMatches(entityId, request.getEntries()), + () -> toCalculatedFieldTelemetryMsgProto(request, result), callback); + } + + @Override + public void pushRequestToQueue(TimeseriesSaveRequest request, FutureCallback callback) { + pushRequestToQueue(request, null, callback); + } + + @Override + public void pushRequestToQueue(AttributesSaveRequest request, List result, FutureCallback callback) { + var tenantId = request.getTenantId(); + var entityId = request.getEntityId(); + checkEntityAndPushToQueue(tenantId, entityId, cf -> cf.matches(request.getEntries(), request.getScope()), cf -> cf.linkMatches(entityId, request.getEntries(), request.getScope()), + () -> toCalculatedFieldTelemetryMsgProto(request, result), callback); + } + + @Override + public void pushRequestToQueue(AttributesSaveRequest request, FutureCallback callback) { + pushRequestToQueue(request, null, callback); + } + + @Override + public void pushRequestToQueue(AttributesDeleteRequest request, List result, FutureCallback callback) { + var tenantId = request.getTenantId(); + var entityId = request.getEntityId(); + checkEntityAndPushToQueue(tenantId, entityId, cf -> cf.matchesKeys(result, request.getScope()), cf -> cf.linkMatchesAttrKeys(entityId, result, request.getScope()), + () -> toCalculatedFieldTelemetryMsgProto(request, result), callback); + } + + @Override + public void pushRequestToQueue(TimeseriesDeleteRequest request, List result, FutureCallback callback) { + var tenantId = request.getTenantId(); + var entityId = request.getEntityId(); + + checkEntityAndPushToQueue(tenantId, entityId, cf -> cf.matchesKeys(result), cf -> cf.linkMatchesTsKeys(entityId, result), + () -> toCalculatedFieldTelemetryMsgProto(request, result), callback); + } + + private void checkEntityAndPushToQueue(TenantId tenantId, EntityId entityId, + Predicate mainEntityFilter, Predicate linkedEntityFilter, + Supplier msg, FutureCallback callback) { + if (EntityType.TENANT.equals(entityId.getEntityType())) { + tenantId = (TenantId) entityId; + } + boolean send = checkEntityForCalculatedFields(tenantId, entityId, mainEntityFilter, linkedEntityFilter); + if (send) { + clusterService.pushMsgToCalculatedFields(tenantId, entityId, msg.get(), wrap(callback)); + } else { + if (callback != null) { + callback.onSuccess(null); + } + } + } + + private boolean checkEntityForCalculatedFields(TenantId tenantId, EntityId entityId, Predicate filter, Predicate linkedEntityFilter) { + boolean send = false; + if (supportedReferencedEntities.contains(entityId.getEntityType())) { + send = calculatedFieldCache.getCalculatedFieldCtxsByEntityId(entityId).stream().anyMatch(filter); + if (!send) { + send = calculatedFieldCache.getCalculatedFieldCtxsByEntityId(getProfileId(tenantId, entityId)).stream().anyMatch(filter); + } + if (!send) { + send = calculatedFieldCache.getCalculatedFieldLinksByEntityId(entityId).stream() + .map(CalculatedFieldLink::getCalculatedFieldId) + .map(calculatedFieldCache::getCalculatedFieldCtx) + .anyMatch(linkedEntityFilter); + } + } + return send; + } + + private EntityId getProfileId(TenantId tenantId, EntityId entityId) { + return switch (entityId.getEntityType()) { + case ASSET -> assetProfileCache.get(tenantId, (AssetId) entityId).getId(); + case DEVICE -> deviceProfileCache.get(tenantId, (DeviceId) entityId).getId(); + default -> null; + }; + } + + private ToCalculatedFieldMsg toCalculatedFieldTelemetryMsgProto(TimeseriesSaveRequest request, TimeseriesSaveResult result) { + ToCalculatedFieldMsg.Builder msg = ToCalculatedFieldMsg.newBuilder(); + + CalculatedFieldTelemetryMsgProto.Builder telemetryMsg = buildTelemetryMsgProto(request.getTenantId(), request.getEntityId(), request.getPreviousCalculatedFieldIds(), request.getTbMsgId(), request.getTbMsgType()); + + List entries = request.getEntries(); + List versions = result != null ? result.getVersions() : Collections.emptyList(); + + for (int i = 0; i < entries.size(); i++) { + TsKvProto.Builder tsProtoBuilder = toTsKvProto(entries.get(i)).toBuilder(); + if (result != null) { + tsProtoBuilder.setVersion(versions.get(i)); + } + telemetryMsg.addTsData(tsProtoBuilder.build()); + } + + msg.setTelemetryMsg(telemetryMsg.build()); + return msg.build(); + } + + private ToCalculatedFieldMsg toCalculatedFieldTelemetryMsgProto(AttributesSaveRequest request, List versions) { + ToCalculatedFieldMsg.Builder msg = ToCalculatedFieldMsg.newBuilder(); + + CalculatedFieldTelemetryMsgProto.Builder telemetryMsg = buildTelemetryMsgProto(request.getTenantId(), request.getEntityId(), request.getPreviousCalculatedFieldIds(), request.getTbMsgId(), request.getTbMsgType()); + telemetryMsg.setScope(AttributeScopeProto.valueOf(request.getScope().name())); + List entries = request.getEntries(); + for (int i = 0; i < entries.size(); i++) { + AttributeValueProto.Builder attrProtoBuilder = ProtoUtils.toProto(entries.get(i)).toBuilder(); + if (versions != null) { + attrProtoBuilder.setVersion(versions.get(i)); + } + telemetryMsg.addAttrData(attrProtoBuilder.build()); + } + msg.setTelemetryMsg(telemetryMsg.build()); + + return msg.build(); + } + + private ToCalculatedFieldMsg toCalculatedFieldTelemetryMsgProto(AttributesDeleteRequest request, List removedKeys) { + CalculatedFieldTelemetryMsgProto telemetryMsg = buildTelemetryMsgProto(request.getTenantId(), request.getEntityId(), request.getPreviousCalculatedFieldIds(), request.getTbMsgId(), request.getTbMsgType()) + .setScope(AttributeScopeProto.valueOf(request.getScope().name())) + .addAllRemovedAttrKeys(removedKeys).build(); + return ToCalculatedFieldMsg.newBuilder() + .setTelemetryMsg(telemetryMsg) + .build(); + } + + private ToCalculatedFieldMsg toCalculatedFieldTelemetryMsgProto(TimeseriesDeleteRequest request, List removedKeys) { + CalculatedFieldTelemetryMsgProto telemetryMsg = buildTelemetryMsgProto(request.getTenantId(), request.getEntityId(), request.getPreviousCalculatedFieldIds(), request.getTbMsgId(), request.getTbMsgType()) + .addAllRemovedTsKeys(removedKeys).build(); + return ToCalculatedFieldMsg.newBuilder() + .setTelemetryMsg(telemetryMsg) + .build(); + } + + private CalculatedFieldTelemetryMsgProto.Builder buildTelemetryMsgProto(TenantId tenantId, EntityId entityId, List calculatedFieldIds, UUID tbMsgId, TbMsgType tbMsgType) { + CalculatedFieldTelemetryMsgProto.Builder telemetryMsg = CalculatedFieldTelemetryMsgProto.newBuilder(); + + if (EntityType.TENANT.equals(entityId.getEntityType())) { + tenantId = (TenantId) entityId; + } + + telemetryMsg.setTenantIdMSB(tenantId.getId().getMostSignificantBits()); + telemetryMsg.setTenantIdLSB(tenantId.getId().getLeastSignificantBits()); + + telemetryMsg.setEntityType(entityId.getEntityType().name()); + telemetryMsg.setEntityIdMSB(entityId.getId().getMostSignificantBits()); + telemetryMsg.setEntityIdLSB(entityId.getId().getLeastSignificantBits()); + + if (calculatedFieldIds != null) { + for (CalculatedFieldId cfId : calculatedFieldIds) { + telemetryMsg.addPreviousCalculatedFields(toProto(cfId)); + } + } + + if (tbMsgId != null) { + telemetryMsg.setTbMsgIdMSB(tbMsgId.getMostSignificantBits()); + telemetryMsg.setTbMsgIdLSB(tbMsgId.getLeastSignificantBits()); + } + + if (tbMsgType != null) { + telemetryMsg.setTbMsgType(tbMsgType.name()); + } + + return telemetryMsg; + } + + private static TbQueueCallback wrap(FutureCallback callback) { + if (callback != null) { + return new FutureCallbackWrapper(callback); + } else { + return DUMMY_TB_QUEUE_CALLBACK; + } + } + + private static class FutureCallbackWrapper implements TbQueueCallback { + private final FutureCallback callback; + + public FutureCallbackWrapper(FutureCallback callback) { + this.callback = callback; + } + + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + callback.onSuccess(null); + } + + @Override + public void onFailure(Throwable t) { + callback.onFailure(t); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/cache/CalculatedFieldEntityProfileCache.java b/application/src/main/java/org/thingsboard/server/service/cf/cache/CalculatedFieldEntityProfileCache.java new file mode 100644 index 0000000000..bb5ef91974 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/cache/CalculatedFieldEntityProfileCache.java @@ -0,0 +1,36 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.cache; + +import org.springframework.context.ApplicationListener; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; + +import java.util.Collection; + +public interface CalculatedFieldEntityProfileCache extends ApplicationListener { + + void add(TenantId tenantId, EntityId profileId, EntityId entityId); + + void update(TenantId tenantId, EntityId oldProfileId, EntityId newProfileId, EntityId entityId); + + void evict(TenantId tenantId, EntityId entityId); + + Collection getMyEntityIdsByProfileId(TenantId tenantId, EntityId profileId); + + int getEntityIdPartition(TenantId tenantId, EntityId entityId); +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/cache/DefaultCalculatedFieldEntityProfileCache.java b/application/src/main/java/org/thingsboard/server/service/cf/cache/DefaultCalculatedFieldEntityProfileCache.java new file mode 100644 index 0000000000..2f5772ae50 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/cache/DefaultCalculatedFieldEntityProfileCache.java @@ -0,0 +1,95 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.cache; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.queue.discovery.TbApplicationEventListener; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; +import org.thingsboard.server.queue.util.TbRuleEngineComponent; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Collectors; + +@TbRuleEngineComponent +@Service +@Slf4j +@RequiredArgsConstructor +//TODO ashvayka: remove and use TenantEntityProfileCache in each CalculatedFieldManagerMessageProcessor; +public class DefaultCalculatedFieldEntityProfileCache extends TbApplicationEventListener implements CalculatedFieldEntityProfileCache { + + private static final Integer UNKNOWN = 0; + private final ConcurrentMap tenantCache = new ConcurrentHashMap<>(); + private final PartitionService partitionService; + private volatile List myPartitions = Collections.emptyList(); + + @Override + protected void onTbApplicationEvent(PartitionChangeEvent event) { + myPartitions = event.getCfPartitions().stream() + .filter(TopicPartitionInfo::isMyPartition) + .map(tpi -> tpi.getPartition().orElse(UNKNOWN)).collect(Collectors.toList()); + //Naive approach that need to be improved. + tenantCache.values().forEach(cache -> cache.setMyPartitions(myPartitions)); + } + + @Override + public void add(TenantId tenantId, EntityId profileId, EntityId entityId) { + var tpi = partitionService.resolve(ServiceType.TB_RULE_ENGINE, DataConstants.CF_QUEUE_NAME, tenantId, entityId); + var partition = tpi.getPartition().orElse(UNKNOWN); + tenantCache.computeIfAbsent(tenantId, id -> new TenantEntityProfileCache()) + .add(profileId, entityId, partition, tpi.isMyPartition()); + } + + @Override + public void update(TenantId tenantId, EntityId oldProfileId, EntityId newProfileId, EntityId entityId) { + var tpi = partitionService.resolve(ServiceType.TB_RULE_ENGINE, DataConstants.CF_QUEUE_NAME, tenantId, entityId); + var partition = tpi.getPartition().orElse(UNKNOWN); + var cache = tenantCache.computeIfAbsent(tenantId, id -> new TenantEntityProfileCache()); + //TODO: make this method atomic; + cache.remove(oldProfileId, entityId); + cache.add(newProfileId, entityId, partition, tpi.isMyPartition()); + } + + @Override + public void evict(TenantId tenantId, EntityId entityId) { + var cache = tenantCache.computeIfAbsent(tenantId, id -> new TenantEntityProfileCache()); + cache.removeEntityId(entityId); + } + + @Override + public Collection getMyEntityIdsByProfileId(TenantId tenantId, EntityId profileId) { + return tenantCache.computeIfAbsent(tenantId, id -> new TenantEntityProfileCache()).getMyEntityIdsByProfileId(profileId); + } + + @Override + public int getEntityIdPartition(TenantId tenantId, EntityId entityId) { + var tpi = partitionService.resolve(ServiceType.TB_RULE_ENGINE, DataConstants.CF_QUEUE_NAME, tenantId, entityId); + return tpi.getPartition().orElse(UNKNOWN); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/cache/TenantEntityProfileCache.java b/application/src/main/java/org/thingsboard/server/service/cf/cache/TenantEntityProfileCache.java new file mode 100644 index 0000000000..1a17b9b8be --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/cache/TenantEntityProfileCache.java @@ -0,0 +1,122 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.cache; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +public class TenantEntityProfileCache { + + private final ReadWriteLock lock = new ReentrantReadWriteLock(); + private final Map>> allEntities = new HashMap<>(); + private final Map> myEntities = new HashMap<>(); + + public void setMyPartitions(List myPartitions) { + lock.writeLock().lock(); + try { + myEntities.clear(); + myPartitions.forEach(partitionId -> { + var map = allEntities.get(partitionId); + if (map != null) { + map.forEach((profileId, entityIds) -> myEntities.computeIfAbsent(profileId, k -> new HashSet<>()).addAll(entityIds)); + } + }); + } finally { + lock.writeLock().unlock(); + } + } + + public void removeProfileId(EntityId profileId) { + lock.writeLock().lock(); + try { + // Remove from allEntities + allEntities.values().forEach(map -> map.remove(profileId)); + // Remove from myEntities + myEntities.remove(profileId); + } finally { + lock.writeLock().unlock(); + } + } + + public void removeEntityId(EntityId entityId) { + lock.writeLock().lock(); + try { + // Remove from allEntities + allEntities.values().forEach(map -> map.values().forEach(set -> set.remove(entityId))); + // Remove from myEntities + myEntities.values().forEach(set -> set.remove(entityId)); + } finally { + lock.writeLock().unlock(); + } + } + + public void remove(EntityId profileId, EntityId entityId) { + lock.writeLock().lock(); + try { + // Remove from allEntities + allEntities.values().forEach(map -> removeSafely(map, profileId, entityId)); + // Remove from myEntities + removeSafely(myEntities, profileId, entityId); + } finally { + lock.writeLock().unlock(); + } + } + + public void add(EntityId profileId, EntityId entityId, Integer partition, boolean mine) { + lock.writeLock().lock(); + try { + if(EntityType.DEVICE.equals(profileId.getEntityType())){ + throw new RuntimeException("WTF?"); + } + if (mine) { + myEntities.computeIfAbsent(profileId, k -> new HashSet<>()).add(entityId); + } + allEntities.computeIfAbsent(partition, k -> new HashMap<>()).computeIfAbsent(profileId, p -> new HashSet<>()).add(entityId); + } finally { + lock.writeLock().unlock(); + } + } + + public Collection getMyEntityIdsByProfileId(EntityId profileId) { + lock.readLock().lock(); + try { + var entities = myEntities.getOrDefault(profileId, Collections.emptySet()); + List result = new ArrayList<>(entities.size()); + result.addAll(entities); + return result; + } finally { + lock.readLock().unlock(); + } + } + + private void removeSafely(Map> map, EntityId profileId, EntityId entityId) { + var set = map.get(profileId); + if (set != null) { + set.remove(entityId); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/CalculatedFieldEntityCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/CalculatedFieldEntityCtx.java new file mode 100644 index 0000000000..6694252652 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/CalculatedFieldEntityCtx.java @@ -0,0 +1,34 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.ctx; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; + +@Data +@NoArgsConstructor +public class CalculatedFieldEntityCtx { + + private CalculatedFieldEntityCtxId id; + private CalculatedFieldState state; + + public CalculatedFieldEntityCtx(CalculatedFieldEntityCtxId id, CalculatedFieldState state) { + this.id = id; + this.state = state; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/CalculatedFieldEntityCtxId.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/CalculatedFieldEntityCtxId.java new file mode 100644 index 0000000000..329028eda4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/CalculatedFieldEntityCtxId.java @@ -0,0 +1,28 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.ctx; + +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; + +public record CalculatedFieldEntityCtxId(TenantId tenantId, CalculatedFieldId cfId, EntityId entityId) { + + public String toKey() { + return cfId + "_" + entityId; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java new file mode 100644 index 0000000000..83e10b8194 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java @@ -0,0 +1,61 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.ctx.state; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; + +import java.util.List; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = SingleValueArgumentEntry.class, name = "SINGLE_VALUE"), + @JsonSubTypes.Type(value = TsRollingArgumentEntry.class, name = "TS_ROLLING") +}) +public interface ArgumentEntry { + + @JsonIgnore + ArgumentEntryType getType(); + + Object getValue(); + + boolean updateEntry(ArgumentEntry entry); + + boolean isEmpty(); + + TbelCfArg toTbelCfArg(); + + boolean isForceResetPrevious(); + + void setForceResetPrevious(boolean forceResetPrevious); + + static ArgumentEntry createSingleValueArgument(KvEntry kvEntry) { + return new SingleValueArgumentEntry(kvEntry); + } + + static ArgumentEntry createTsRollingArgument(List kvEntries, int limit, long timeWindow) { + return new TsRollingArgumentEntry(kvEntries, limit, timeWindow); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java new file mode 100644 index 0000000000..68f973c7c1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java @@ -0,0 +1,20 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.ctx.state; + +public enum ArgumentEntryType { + SINGLE_VALUE, TS_ROLLING +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java new file mode 100644 index 0000000000..80b003b3cc --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java @@ -0,0 +1,103 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.ctx.state; + +import lombok.AllArgsConstructor; +import lombok.Data; +import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; +import org.thingsboard.server.utils.CalculatedFieldUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.thingsboard.server.utils.CalculatedFieldUtils.toSingleValueArgumentProto; + +@Data +@AllArgsConstructor +public abstract class BaseCalculatedFieldState implements CalculatedFieldState { + + protected List requiredArguments; + protected Map arguments; + protected boolean sizeExceedsLimit; + + public BaseCalculatedFieldState(List requiredArguments) { + this.requiredArguments = requiredArguments; + this.arguments = new HashMap<>(); + } + + public BaseCalculatedFieldState() { + this(new ArrayList<>(), new HashMap<>(), false); + } + + @Override + public boolean updateState(CalculatedFieldCtx ctx, Map argumentValues) { + if (arguments == null) { + arguments = new HashMap<>(); + } + + boolean stateUpdated = false; + + for (Map.Entry entry : argumentValues.entrySet()) { + String key = entry.getKey(); + ArgumentEntry newEntry = entry.getValue(); + + checkArgumentSize(key, newEntry, ctx); + + ArgumentEntry existingEntry = arguments.get(key); + + if (existingEntry == null || newEntry.isForceResetPrevious()) { + validateNewEntry(newEntry); + arguments.put(key, newEntry); + stateUpdated = true; + } else { + stateUpdated = existingEntry.updateEntry(newEntry); + } + } + + return stateUpdated; + } + + @Override + public boolean isReady() { + return arguments.keySet().containsAll(requiredArguments) && + arguments.values().stream().noneMatch(ArgumentEntry::isEmpty); + } + + @Override + public void checkStateSize(CalculatedFieldEntityCtxId ctxId, long maxStateSize) { + if (!sizeExceedsLimit && maxStateSize > 0 && CalculatedFieldUtils.toProto(ctxId, this).getSerializedSize() > maxStateSize) { + arguments.clear(); + sizeExceedsLimit = true; + } + } + + @Override + public void checkArgumentSize(String name, ArgumentEntry entry, CalculatedFieldCtx ctx) { + if (entry instanceof TsRollingArgumentEntry) { + return; + } + if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) { + if (ctx.getMaxSingleValueArgumentSize() > 0 && toSingleValueArgumentProto(name, singleValueArgumentEntry).getSerializedSize() > ctx.getMaxSingleValueArgumentSize()) { + throw new IllegalArgumentException("Single value size exceeds the maximum allowed limit. The argument will not be used for calculation."); + } + } + } + + protected abstract void validateNewEntry(ArgumentEntry newEntry); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java new file mode 100644 index 0000000000..0c4352dcea --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java @@ -0,0 +1,273 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.ctx.state; + +import lombok.Data; +import net.objecthunter.exp4j.Expression; +import net.objecthunter.exp4j.ExpressionBuilder; +import org.mvel2.MVEL; +import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.util.ProtoUtils; +import org.thingsboard.server.dao.usagerecord.ApiLimitService; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; +import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Data +public class CalculatedFieldCtx { + + private CalculatedField calculatedField; + + private CalculatedFieldId cfId; + private TenantId tenantId; + private EntityId entityId; + private CalculatedFieldType cfType; + private final Map arguments; + private final Map mainEntityArguments; + private final Map> linkedEntityArguments; + private final List argNames; + private Output output; + private String expression; + private TbelInvokeService tbelInvokeService; + private CalculatedFieldScriptEngine calculatedFieldScriptEngine; + private ThreadLocal customExpression; + + private boolean initialized; + + private long maxDataPointsPerRollingArg; + private long maxStateSize; + private long maxSingleValueArgumentSize; + + public CalculatedFieldCtx(CalculatedField calculatedField, TbelInvokeService tbelInvokeService, ApiLimitService apiLimitService) { + this.calculatedField = calculatedField; + + this.cfId = calculatedField.getId(); + this.tenantId = calculatedField.getTenantId(); + this.entityId = calculatedField.getEntityId(); + this.cfType = calculatedField.getType(); + CalculatedFieldConfiguration configuration = calculatedField.getConfiguration(); + this.arguments = configuration.getArguments(); + this.mainEntityArguments = new HashMap<>(); + this.linkedEntityArguments = new HashMap<>(); + for (Map.Entry entry : arguments.entrySet()) { + var refId = entry.getValue().getRefEntityId(); + var refKey = entry.getValue().getRefEntityKey(); + if (refId == null || refId.equals(calculatedField.getEntityId())) { + mainEntityArguments.put(refKey, entry.getKey()); + } else { + linkedEntityArguments.computeIfAbsent(refId, key -> new HashMap<>()).put(refKey, entry.getKey()); + } + } + this.argNames = new ArrayList<>(arguments.keySet()); + this.output = configuration.getOutput(); + this.expression = configuration.getExpression(); + this.tbelInvokeService = tbelInvokeService; + + this.maxDataPointsPerRollingArg = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg); + this.maxStateSize = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxStateSizeInKBytes) * 1024; + this.maxSingleValueArgumentSize = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxSingleValueArgumentSizeInKBytes) * 1024; + } + + public void init() { + if (CalculatedFieldType.SCRIPT.equals(cfType)) { + try { + this.calculatedFieldScriptEngine = initEngine(tenantId, expression, tbelInvokeService); + initialized = true; + } catch (Exception e) { + throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax.", e); + } + } else { + if (isValidExpression(expression)) { + this.customExpression = ThreadLocal.withInitial(() -> + new ExpressionBuilder(expression) + .implicitMultiplication(true) + .variables(this.arguments.keySet()) + .build() + ); + initialized = true; + } else { + throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax."); + } + } + } + + private CalculatedFieldScriptEngine initEngine(TenantId tenantId, String expression, TbelInvokeService tbelInvokeService) { + if (tbelInvokeService == null) { + throw new IllegalArgumentException("TBEL script engine is disabled!"); + } + + List ctxAndArgNames = new ArrayList<>(argNames.size() + 1); + ctxAndArgNames.add("ctx"); + ctxAndArgNames.addAll(argNames); + return new CalculatedFieldTbelScriptEngine( + tenantId, + tbelInvokeService, + expression, + ctxAndArgNames.toArray(String[]::new) + ); + } + + private boolean isValidExpression(String expression) { + try { + MVEL.compileExpression(expression); + return true; + } catch (Exception e) { + return false; + } + } + + public boolean matches(List values, AttributeScope scope) { + return matchesAttributes(mainEntityArguments, values, scope); + } + + public boolean linkMatches(EntityId entityId, List values, AttributeScope scope) { + var map = linkedEntityArguments.get(entityId); + return map != null && matchesAttributes(map, values, scope); + } + + public boolean matches(List values) { + return matchesTimeSeries(mainEntityArguments, values); + } + + public boolean linkMatches(EntityId entityId, List values) { + var map = linkedEntityArguments.get(entityId); + return map != null && matchesTimeSeries(map, values); + } + + private boolean matchesAttributes(Map argMap, List values, AttributeScope scope) { + for (AttributeKvEntry attrKv : values) { + ReferencedEntityKey attrKey = new ReferencedEntityKey(attrKv.getKey(), ArgumentType.ATTRIBUTE, scope); + if (argMap.containsKey(attrKey)) { + return true; + } + } + return false; + } + + private boolean matchesTimeSeries(Map argMap, List values) { + for (TsKvEntry tsKv : values) { + ReferencedEntityKey latestKey = new ReferencedEntityKey(tsKv.getKey(), ArgumentType.TS_LATEST, null); + if (argMap.containsKey(latestKey)) { + return true; + } + ReferencedEntityKey rollingKey = new ReferencedEntityKey(tsKv.getKey(), ArgumentType.TS_ROLLING, null); + if (argMap.containsKey(rollingKey)) { + return true; + } + } + return false; + } + + public boolean matchesKeys(List keys, AttributeScope scope) { + return matchesAttributesKeys(mainEntityArguments, keys, scope); + } + + public boolean matchesKeys(List keys) { + return matchesTimeSeriesKeys(mainEntityArguments, keys); + } + + private boolean matchesAttributesKeys(Map argMap, List keys, AttributeScope scope) { + for (String key : keys) { + ReferencedEntityKey attrKey = new ReferencedEntityKey(key, ArgumentType.ATTRIBUTE, scope); + if (argMap.containsKey(attrKey)) { + return true; + } + } + return false; + } + + private boolean matchesTimeSeriesKeys(Map argMap, List keys) { + for (String key : keys) { + ReferencedEntityKey latestKey = new ReferencedEntityKey(key, ArgumentType.TS_LATEST, null); + if (argMap.containsKey(latestKey)) { + return true; + } + ReferencedEntityKey rollingKey = new ReferencedEntityKey(key, ArgumentType.TS_ROLLING, null); + if (argMap.containsKey(rollingKey)) { + return true; + } + } + return false; + } + + public boolean linkMatchesAttrKeys(EntityId entityId, List keys, AttributeScope scope) { + var map = linkedEntityArguments.get(entityId); + return map != null && matchesAttributesKeys(map, keys, scope); + } + + public boolean linkMatchesTsKeys(EntityId entityId, List keys) { + var map = linkedEntityArguments.get(entityId); + return map != null && matchesTimeSeriesKeys(map, keys); + } + + public boolean linkMatches(EntityId entityId, CalculatedFieldTelemetryMsgProto proto) { + if (!proto.getTsDataList().isEmpty()) { + List updatedTelemetry = proto.getTsDataList().stream() + .map(ProtoUtils::fromProto) + .toList(); + return linkMatches(entityId, updatedTelemetry); + } else if (!proto.getAttrDataList().isEmpty()) { + AttributeScope scope = AttributeScope.valueOf(proto.getScope().name()); + List updatedTelemetry = proto.getAttrDataList().stream() + .map(ProtoUtils::fromProto) + .toList(); + return linkMatches(entityId, updatedTelemetry, scope); + } else if (!proto.getRemovedTsKeysList().isEmpty()) { + return linkMatchesTsKeys(entityId, proto.getRemovedTsKeysList()); + } else { + return linkMatchesAttrKeys(entityId, proto.getRemovedAttrKeysList(), AttributeScope.valueOf(proto.getScope().name())); + } + } + + public CalculatedFieldEntityCtxId toCalculatedFieldEntityCtxId() { + return new CalculatedFieldEntityCtxId(tenantId, cfId, entityId); + } + + public boolean hasOtherSignificantChanges(CalculatedFieldCtx other) { + boolean expressionChanged = !expression.equals(other.expression); + boolean outputChanged = !output.equals(other.output); + return expressionChanged || outputChanged; + } + + public boolean hasStateChanges(CalculatedFieldCtx other) { + boolean typeChanged = !cfType.equals(other.cfType); + boolean argumentsChanged = !arguments.equals(other.arguments); + return typeChanged || argumentsChanged; + } + + public String getSizeExceedsLimitMessage() { + return "Failed to init CF state. State size exceeds limit of " + (maxStateSize / 1024) + "Kb!"; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldScriptEngine.java new file mode 100644 index 0000000000..caad1e4cfe --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldScriptEngine.java @@ -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. + */ +package org.thingsboard.server.service.cf.ctx.state; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.ListenableFuture; + +public interface CalculatedFieldScriptEngine { + + ListenableFuture executeScriptAsync(Object[] args); + + ListenableFuture executeJsonAsync(Object[] args); + + void destroy(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java new file mode 100644 index 0000000000..fc4ba513d2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java @@ -0,0 +1,65 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.ctx.state; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.service.cf.CalculatedFieldResult; +import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; + +import java.util.List; +import java.util.Map; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = SimpleCalculatedFieldState.class, name = "SIMPLE"), + @JsonSubTypes.Type(value = ScriptCalculatedFieldState.class, name = "SCRIPT"), +}) +public interface CalculatedFieldState { + + @JsonIgnore + CalculatedFieldType getType(); + + Map getArguments(); + + void setRequiredArguments(List requiredArguments); + + boolean updateState(CalculatedFieldCtx ctx, Map argumentValues); + + ListenableFuture performCalculation(CalculatedFieldCtx ctx); + + @JsonIgnore + boolean isReady(); + + boolean isSizeExceedsLimit(); + + @JsonIgnore + default boolean isSizeOk() { + return !isSizeExceedsLimit(); + } + + void checkStateSize(CalculatedFieldEntityCtxId ctxId, long maxStateSize); + + void checkArgumentSize(String name, ArgumentEntry entry, CalculatedFieldCtx ctx); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldTbelScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldTbelScriptEngine.java new file mode 100644 index 0000000000..2ca34b7b17 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldTbelScriptEngine.java @@ -0,0 +1,82 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.ctx.state; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.script.api.ScriptType; +import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.common.data.id.TenantId; + +import javax.script.ScriptException; +import java.util.UUID; +import java.util.concurrent.ExecutionException; + +@Slf4j +public class CalculatedFieldTbelScriptEngine implements CalculatedFieldScriptEngine { + + private final TbelInvokeService tbelInvokeService; + + private final UUID scriptId; + private final TenantId tenantId; + + public CalculatedFieldTbelScriptEngine(TenantId tenantId, TbelInvokeService tbelInvokeService, String script, String... argNames) { + this.tenantId = tenantId; + this.tbelInvokeService = tbelInvokeService; + try { + this.scriptId = this.tbelInvokeService.eval(tenantId, ScriptType.CALCULATED_FIELD_SCRIPT, script, argNames).get(); + } catch (Exception e) { + Throwable t = e; + if (e instanceof ExecutionException) { + t = e.getCause(); + } + throw new IllegalArgumentException("Can't compile script: " + t.getMessage(), t); + } + } + + @Override + public ListenableFuture executeScriptAsync(Object[] args) { + log.trace("Executing script async, args {}", args); + return Futures.transformAsync(tbelInvokeService.invokeScript(tenantId, null, this.scriptId, args), + o -> { + try { + return Futures.immediateFuture(o); + } catch (Exception e) { + if (e.getCause() instanceof ScriptException) { + return Futures.immediateFailedFuture(e.getCause()); + } else if (e.getCause() instanceof RuntimeException) { + return Futures.immediateFailedFuture(new ScriptException(e.getCause().getMessage())); + } else { + return Futures.immediateFailedFuture(new ScriptException(e)); + } + } + }, MoreExecutors.directExecutor()); + } + + @Override + public ListenableFuture executeJsonAsync(Object[] args) { + return Futures.transform(executeScriptAsync(args), JacksonUtil::valueToTree, MoreExecutors.directExecutor()); + } + + @Override + public void destroy() { + tbelInvokeService.release(this.scriptId); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/KafkaCalculatedFieldStateService.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/KafkaCalculatedFieldStateService.java new file mode 100644 index 0000000000..90d4056afc --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/KafkaCalculatedFieldStateService.java @@ -0,0 +1,153 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.ctx.state; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueMsgHeaders; +import org.thingsboard.server.queue.TbQueueMsgMetadata; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; +import org.thingsboard.server.queue.common.state.KafkaQueueStateService; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.queue.kafka.TbKafkaProducerTemplate; +import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory; +import org.thingsboard.server.service.cf.AbstractCalculatedFieldStateService; +import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.thingsboard.server.queue.common.AbstractTbQueueTemplate.bytesToString; +import static org.thingsboard.server.queue.common.AbstractTbQueueTemplate.bytesToUuid; +import static org.thingsboard.server.queue.common.AbstractTbQueueTemplate.stringToBytes; +import static org.thingsboard.server.queue.common.AbstractTbQueueTemplate.uuidToBytes; + +@Service +@RequiredArgsConstructor +@Slf4j +@ConditionalOnExpression("('${service.type:null}'=='monolith' || '${service.type:null}'=='tb-rule-engine') && '${queue.type:null}'=='kafka'") +public class KafkaCalculatedFieldStateService extends AbstractCalculatedFieldStateService { + + private final TbRuleEngineQueueFactory queueFactory; + private final PartitionService partitionService; + + @Value("${queue.calculated_fields.poll_interval:25}") + private long pollInterval; + + private TbKafkaProducerTemplate> stateProducer; + + private final AtomicInteger counter = new AtomicInteger(); + + @Override + public void init(PartitionedQueueConsumerManager> eventConsumer) { + var queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, DataConstants.CF_STATES_QUEUE_NAME); + PartitionedQueueConsumerManager> stateConsumer = PartitionedQueueConsumerManager.>create() + .queueKey(queueKey) + .topic(partitionService.getTopic(queueKey)) + .pollInterval(pollInterval) + .msgPackProcessor((msgs, consumer, config) -> { + for (TbProtoQueueMsg msg : msgs) { + try { + if (msg.getValue() != null) { + processRestoredState(msg.getValue()); + } else { + processRestoredState(getStateId(msg.getHeaders()), null); + } + } catch (Throwable t) { + log.error("Failed to process state message: {}", msg, t); + } + + int processedMsgCount = counter.incrementAndGet(); + if (processedMsgCount % 10000 == 0) { + log.info("Processed {} calculated field state msgs", processedMsgCount); + } + } + }) + .consumerCreator((config, partitionId) -> queueFactory.createCalculatedFieldStateConsumer()) + .queueAdmin(queueFactory.getCalculatedFieldQueueAdmin()) + .consumerExecutor(eventConsumer.getConsumerExecutor()) + .scheduler(eventConsumer.getScheduler()) + .taskExecutor(eventConsumer.getTaskExecutor()) + .build(); + super.stateService = new KafkaQueueStateService<>(eventConsumer, stateConsumer); + this.stateProducer = (TbKafkaProducerTemplate>) queueFactory.createCalculatedFieldStateProducer(); + } + + @Override + protected void doPersist(CalculatedFieldEntityCtxId stateId, CalculatedFieldStateProto stateMsgProto, TbCallback callback) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_RULE_ENGINE, DataConstants.CF_STATES_QUEUE_NAME, stateId.tenantId(), stateId.entityId()); + TbProtoQueueMsg msg = new TbProtoQueueMsg<>(stateId.entityId().getId(), stateMsgProto); + if (stateMsgProto == null) { + putStateId(msg.getHeaders(), stateId); + } + stateProducer.send(tpi, stateId.toKey(), msg, new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + if (callback != null) { + callback.onSuccess(); + } + } + + @Override + public void onFailure(Throwable t) { + if (callback != null) { + callback.onFailure(t); + } + } + }); + } + + @Override + protected void doRemove(CalculatedFieldEntityCtxId stateId, TbCallback callback) { + doPersist(stateId, null, callback); + } + + private void putStateId(TbQueueMsgHeaders headers, CalculatedFieldEntityCtxId stateId) { + headers.put("tenantId", uuidToBytes(stateId.tenantId().getId())); + headers.put("cfId", uuidToBytes(stateId.cfId().getId())); + headers.put("entityId", uuidToBytes(stateId.entityId().getId())); + headers.put("entityType", stringToBytes(stateId.entityId().getEntityType().name())); + } + + private CalculatedFieldEntityCtxId getStateId(TbQueueMsgHeaders headers) { + TenantId tenantId = TenantId.fromUUID(bytesToUuid(headers.get("tenantId"))); + CalculatedFieldId cfId = new CalculatedFieldId(bytesToUuid(headers.get("cfId"))); + EntityId entityId = EntityIdFactory.getByTypeAndUuid(bytesToString(headers.get("entityType")), bytesToUuid(headers.get("entityId"))); + return new CalculatedFieldEntityCtxId(tenantId, cfId, entityId); + } + + @Override + public void stop() { + super.stop(); + stateProducer.stop(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/RocksDBCalculatedFieldStateService.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/RocksDBCalculatedFieldStateService.java new file mode 100644 index 0000000000..0eaa506dfd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/RocksDBCalculatedFieldStateService.java @@ -0,0 +1,76 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.ctx.state; + +import com.google.protobuf.InvalidProtocolBufferException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.queue.common.state.DefaultQueueStateService; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; +import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.service.cf.AbstractCalculatedFieldStateService; +import org.thingsboard.server.service.cf.CfRocksDb; +import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; + +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Slf4j +@ConditionalOnExpression("'${queue.type:null}'=='in-memory'") +public class RocksDBCalculatedFieldStateService extends AbstractCalculatedFieldStateService { + + private final CfRocksDb cfRocksDb; + + @Override + public void init(PartitionedQueueConsumerManager> eventConsumer) { + super.stateService = new DefaultQueueStateService<>(eventConsumer); + } + + @Override + protected void doPersist(CalculatedFieldEntityCtxId stateId, CalculatedFieldStateProto stateMsgProto, TbCallback callback) { + cfRocksDb.put(stateId.toKey(), stateMsgProto.toByteArray()); + callback.onSuccess(); + } + + @Override + protected void doRemove(CalculatedFieldEntityCtxId stateId, TbCallback callback) { + cfRocksDb.delete(stateId.toKey()); + callback.onSuccess(); + } + + @Override + public void restore(QueueKey queueKey, Set partitions) { + if (stateService.getPartitions().isEmpty()) { + cfRocksDb.forEach((key, value) -> { + try { + processRestoredState(CalculatedFieldStateProto.parseFrom(value)); + } catch (InvalidProtocolBufferException e) { + log.error("[{}] Failed to process restored state", key, e); + } + }); + } + super.restore(queueKey, partitions); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java new file mode 100644 index 0000000000..bf00f1b0b1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java @@ -0,0 +1,83 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.ctx.state; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.script.api.tbel.TbelCfCtx; +import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.service.cf.CalculatedFieldResult; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Data +@Slf4j +@NoArgsConstructor +public class ScriptCalculatedFieldState extends BaseCalculatedFieldState { + + public ScriptCalculatedFieldState(List requiredArguments) { + super(requiredArguments); + } + + @Override + public CalculatedFieldType getType() { + return CalculatedFieldType.SCRIPT; + } + + @Override + protected void validateNewEntry(ArgumentEntry newEntry) { + } + + @Override + public ListenableFuture performCalculation(CalculatedFieldCtx ctx) { + Map arguments = new LinkedHashMap<>(); + List args = new ArrayList<>(ctx.getArgNames().size() + 1); + args.add(new Object()); // first element is a ctx, but we will set it later; + for (String argName : ctx.getArgNames()) { + var arg = toTbelArgument(argName); + arguments.put(argName, arg); + if (arg instanceof TbelCfSingleValueArg svArg) { + args.add(svArg.getValue()); + } else { + args.add(arg); + } + } + args.set(0, new TbelCfCtx(arguments)); + ListenableFuture resultFuture = ctx.getCalculatedFieldScriptEngine().executeJsonAsync(args.toArray()); + Output output = ctx.getOutput(); + return Futures.transform(resultFuture, + result -> new CalculatedFieldResult(output.getType(), output.getScope(), result), + MoreExecutors.directExecutor() + ); + } + + private TbelCfArg toTbelArgument(String key) { + return arguments.get(key).toTbelCfArg(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java new file mode 100644 index 0000000000..480b334ac3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java @@ -0,0 +1,83 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.ctx.state; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.script.api.tbel.TbUtils; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.kv.BasicKvEntry; +import org.thingsboard.server.service.cf.CalculatedFieldResult; + +import java.util.List; +import java.util.Map; + +@Data +@NoArgsConstructor +public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { + + public SimpleCalculatedFieldState(List requiredArguments) { + super(requiredArguments); + } + + @Override + public CalculatedFieldType getType() { + return CalculatedFieldType.SIMPLE; + } + + @Override + protected void validateNewEntry(ArgumentEntry newEntry) { + if (newEntry instanceof TsRollingArgumentEntry) { + throw new IllegalArgumentException("Rolling argument entry is not supported for simple calculated fields."); + } + } + + @Override + public ListenableFuture performCalculation(CalculatedFieldCtx ctx) { + var expr = ctx.getCustomExpression().get(); + + for (Map.Entry entry : this.arguments.entrySet()) { + try { + BasicKvEntry kvEntry = ((SingleValueArgumentEntry) entry.getValue()).getKvEntryValue(); + expr.setVariable(entry.getKey(), Double.parseDouble(kvEntry.getValueAsString())); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Argument '" + entry.getKey() + "' is not a number."); + } + } + + double expressionResult = expr.evaluate(); + + Output output = ctx.getOutput(); + Object result; + Integer decimals = output.getDecimalsByDefault(); + if (decimals != null) { + if (decimals.equals(0)) { + result = TbUtils.toInt(expressionResult); + } else { + result = TbUtils.toFixed(expressionResult, decimals); + } + } else { + result = expressionResult; + } + + return Futures.immediateFuture(new CalculatedFieldResult(output.getType(), output.getScope(), JacksonUtil.valueToTree(Map.of(output.getName(), result)))); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java new file mode 100644 index 0000000000..bfe9eed24f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java @@ -0,0 +1,115 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.ctx.state; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BasicKvEntry; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.util.ProtoUtils; +import org.thingsboard.server.gen.transport.TransportProtos.AttributeValueProto; +import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SingleValueArgumentEntry implements ArgumentEntry { + + private long ts; + private BasicKvEntry kvEntryValue; + private Long version; + + private boolean forceResetPrevious; + + public SingleValueArgumentEntry(TsKvProto entry) { + this.ts = entry.getTs(); + if (entry.hasVersion()) { + this.version = entry.getVersion(); + } + this.kvEntryValue = ProtoUtils.fromProto(entry.getKv()); + } + + public SingleValueArgumentEntry(AttributeValueProto entry) { + this.ts = entry.getLastUpdateTs(); + if (entry.hasVersion()) { + this.version = entry.getVersion(); + } + this.kvEntryValue = ProtoUtils.basicKvEntryFromProto(entry); + } + + public SingleValueArgumentEntry(KvEntry entry) { + if (entry instanceof TsKvEntry tsKvEntry) { + this.ts = tsKvEntry.getTs(); + this.version = tsKvEntry.getVersion(); + } else if (entry instanceof AttributeKvEntry attributeKvEntry) { + this.ts = attributeKvEntry.getLastUpdateTs(); + this.version = attributeKvEntry.getVersion(); + } + this.kvEntryValue = ProtoUtils.basicKvEntryFromKvEntry(entry); + } + + public SingleValueArgumentEntry(long ts, BasicKvEntry kvEntryValue, Long version) { + this.ts = ts; + this.kvEntryValue = kvEntryValue; + this.version = version; + } + + @Override + public ArgumentEntryType getType() { + return ArgumentEntryType.SINGLE_VALUE; + } + + @Override + public boolean isEmpty() { + return kvEntryValue == null; + } + + @JsonIgnore + public Object getValue() { + return isEmpty() ? null : kvEntryValue.getValue(); + } + + @Override + public TbelCfArg toTbelCfArg() { + return new TbelCfSingleValueArg(ts, kvEntryValue.getValue()); + } + + @Override + public boolean updateEntry(ArgumentEntry entry) { + if (entry instanceof SingleValueArgumentEntry singleValueEntry) { + if (singleValueEntry.getTs() == this.ts) { + return false; + } + + Long newVersion = singleValueEntry.getVersion(); + if (newVersion == null || this.version == null || newVersion > this.version) { + this.ts = singleValueEntry.getTs(); + this.version = newVersion; + this.kvEntryValue = singleValueEntry.getKvEntryValue(); + return true; + } + } else { + throw new IllegalArgumentException("Unsupported argument entry type for single value argument entry: " + entry.getType()); + } + return false; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntry.java new file mode 100644 index 0000000000..b5a680a072 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntry.java @@ -0,0 +1,146 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.ctx.state; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.script.api.tbel.TbelCfTsDoubleVal; +import org.thingsboard.script.api.tbel.TbelCfTsRollingArg; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Slf4j +public class TsRollingArgumentEntry implements ArgumentEntry { + + private Integer limit; + private Long timeWindow; + private TreeMap tsRecords = new TreeMap<>(); + + private boolean forceResetPrevious; + + public TsRollingArgumentEntry(List kvEntries, int limit, long timeWindow) { + this.limit = limit; + this.timeWindow = timeWindow; + kvEntries.forEach(tsKvEntry -> addTsRecord(tsKvEntry.getTs(), tsKvEntry)); + } + + public TsRollingArgumentEntry(TreeMap tsRecords, int limit, long timeWindow) { + this.tsRecords = tsRecords; + this.limit = limit; + this.timeWindow = timeWindow; + } + + public TsRollingArgumentEntry(int limit, long timeWindow) { + this.tsRecords = new TreeMap<>(); + this.limit = limit; + this.timeWindow = timeWindow; + } + + public TsRollingArgumentEntry(Integer limit, Long timeWindow, TreeMap tsRecords) { + this.limit = limit; + this.timeWindow = timeWindow; + this.tsRecords = tsRecords; + } + + @Override + public ArgumentEntryType getType() { + return ArgumentEntryType.TS_ROLLING; + } + + @Override + public boolean isEmpty() { + return tsRecords.isEmpty(); + } + + @JsonIgnore + @Override + public Object getValue() { + return tsRecords; + } + + @Override + public TbelCfArg toTbelCfArg() { + List values = new ArrayList<>(tsRecords.size()); + for (var e : tsRecords.entrySet()) { + values.add(new TbelCfTsDoubleVal(e.getKey(), e.getValue())); + } + return new TbelCfTsRollingArg(timeWindow, values); + } + + @Override + public boolean updateEntry(ArgumentEntry entry) { + if (entry instanceof TsRollingArgumentEntry tsRollingEntry) { + updateTsRollingEntry(tsRollingEntry); + } else if (entry instanceof SingleValueArgumentEntry singleValueEntry) { + updateSingleValueEntry(singleValueEntry); + } else { + throw new IllegalArgumentException("Unsupported argument entry type for rolling argument entry: " + entry.getType()); + } + return true; + } + + private void updateTsRollingEntry(TsRollingArgumentEntry tsRollingEntry) { + for (Map.Entry tsRecordEntry : tsRollingEntry.getTsRecords().entrySet()) { + addTsRecord(tsRecordEntry.getKey(), tsRecordEntry.getValue()); + } + } + + private void updateSingleValueEntry(SingleValueArgumentEntry singleValueEntry) { + addTsRecord(singleValueEntry.getTs(), singleValueEntry.getKvEntryValue()); + } + + private void addTsRecord(Long ts, KvEntry value) { + try { + switch (value.getDataType()) { + case LONG -> value.getLongValue().ifPresent(aLong -> tsRecords.put(ts, aLong.doubleValue())); + case DOUBLE -> value.getDoubleValue().ifPresent(aDouble -> tsRecords.put(ts, aDouble)); + case BOOLEAN -> value.getBooleanValue().ifPresent(aBoolean -> tsRecords.put(ts, aBoolean ? 1.0 : 0.0)); + case STRING -> value.getStrValue().ifPresent(aString -> tsRecords.put(ts, Double.parseDouble(aString))); + case JSON -> value.getJsonValue().ifPresent(aString -> tsRecords.put(ts, Double.parseDouble(aString))); + } + } catch (Exception e) { + tsRecords.put(ts, Double.NaN); + log.debug("Invalid value '{}' for time series rolling arguments. Only numeric values are supported.", value.getValue()); + } finally { + cleanupExpiredRecords(); + } + } + + private void addTsRecord(Long ts, double value) { + tsRecords.put(ts, value); + cleanupExpiredRecords(); + } + + private void cleanupExpiredRecords() { + if (tsRecords.size() > limit) { + tsRecords.pollFirstEntry(); + } + tsRecords.entrySet().removeIf(tsRecord -> tsRecord.getKey() < System.currentTimeMillis() - timeWindow); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java index 6f9845b9a5..c62a551310 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java @@ -82,6 +82,11 @@ public class EdgeEventSourcingListener { @TransactionalEventListener(fallbackExecution = true) public void handleEvent(SaveEntityEvent event) { + if (Boolean.FALSE.equals(event.getBroadcastEvent())) { + log.trace("Ignoring event {}", event); + return; + } + try { if (!isValidSaveEntityEventForEdgeProcessing(event)) { return; diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java index 9f0e184853..a970097548 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java @@ -74,6 +74,8 @@ import org.thingsboard.server.gen.edge.v1.RequestMsgType; import org.thingsboard.server.gen.edge.v1.ResourceUpdateMsg; import org.thingsboard.server.gen.edge.v1.ResponseMsg; import org.thingsboard.server.gen.edge.v1.RuleChainMetadataRequestMsg; +import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg; +import org.thingsboard.server.gen.edge.v1.RuleChainUpdateMsg; import org.thingsboard.server.gen.edge.v1.SyncCompletedMsg; import org.thingsboard.server.gen.edge.v1.UplinkMsg; import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; @@ -820,6 +822,16 @@ public abstract class EdgeGrpcSession implements Closeable { result.add(ctx.getAssetProcessor().processAssetMsgFromEdge(edge.getTenantId(), edge, assetUpdateMsg)); } } + if (uplinkMsg.getRuleChainUpdateMsgCount() > 0) { + for (RuleChainUpdateMsg ruleChainUpdateMsg : uplinkMsg.getRuleChainUpdateMsgList()) { + result.add(ctx.getRuleChainProcessor().processRuleChainMsgFromEdge(edge.getTenantId(), edge, ruleChainUpdateMsg)); + } + } + if (uplinkMsg.getRuleChainMetadataUpdateMsgCount() > 0) { + for (RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg : uplinkMsg.getRuleChainMetadataUpdateMsgList()) { + result.add(ctx.getRuleChainProcessor().processRuleChainMetadataMsgFromEdge(edge.getTenantId(), edge, ruleChainMetadataUpdateMsg)); + } + } if (uplinkMsg.getEntityViewUpdateMsgCount() > 0) { for (EntityViewUpdateMsg entityViewUpdateMsg : uplinkMsg.getEntityViewUpdateMsgList()) { result.add(ctx.getEntityViewProcessor().processEntityViewMsgFromEdge(edge.getTenantId(), edge, entityViewUpdateMsg)); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/edge/EdgeEntityProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/edge/EdgeEntityProcessor.java index ddbe4810df..77fa31c028 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/edge/EdgeEntityProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/edge/EdgeEntityProcessor.java @@ -49,8 +49,12 @@ public class EdgeEntityProcessor extends BaseEdgeProcessor { @Override public ListenableFuture processEntityNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { try { + EdgeId originatorEdgeId = safeGetEdgeId(edgeNotificationMsg.getOriginatorEdgeIdMSB(), edgeNotificationMsg.getOriginatorEdgeIdLSB()); EdgeEventActionType actionType = EdgeEventActionType.valueOf(edgeNotificationMsg.getAction()); EdgeId edgeId = new EdgeId(new UUID(edgeNotificationMsg.getEntityIdMSB(), edgeNotificationMsg.getEntityIdLSB())); + if (edgeId.equals(originatorEdgeId)) { + return Futures.immediateFuture(null); + } switch (actionType) { case ASSIGNED_TO_CUSTOMER: { CustomerId customerId = JacksonUtil.fromString(edgeNotificationMsg.getBody(), CustomerId.class); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/BaseRuleChainProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/BaseRuleChainProcessor.java new file mode 100644 index 0000000000..00b9c732aa --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/BaseRuleChainProcessor.java @@ -0,0 +1,82 @@ +/** + * 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. + */ +package org.thingsboard.server.service.edge.rpc.processor.rule; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.util.Pair; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg; +import org.thingsboard.server.gen.edge.v1.RuleChainUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +import java.util.function.Function; + +@Slf4j +public class BaseRuleChainProcessor extends BaseEdgeProcessor { + + @Autowired + private DataValidator ruleChainValidator; + + protected Pair saveOrUpdateRuleChain(TenantId tenantId, RuleChainId ruleChainId, RuleChainUpdateMsg ruleChainUpdateMsg, RuleChainType ruleChainType) { + boolean created = false; + RuleChain ruleChainFromDb = edgeCtx.getRuleChainService().findRuleChainById(tenantId, ruleChainId); + if (ruleChainFromDb == null) { + created = true; + } + + RuleChain ruleChain = JacksonUtil.fromString(ruleChainUpdateMsg.getEntity(), RuleChain.class, true); + if (ruleChain == null) { + throw new RuntimeException("[{" + tenantId + "}] ruleChainUpdateMsg {" + ruleChainUpdateMsg + "} cannot be converted to rule chain"); + } + boolean isRoot = ruleChain.isRoot(); + if (RuleChainType.CORE.equals(ruleChainType)) { + ruleChain.setRoot(false); + } else { + ruleChain.setRoot(ruleChainFromDb == null ? false : ruleChainFromDb.isRoot()); + } + ruleChain.setType(ruleChainType); + + ruleChainValidator.validate(ruleChain, RuleChain::getTenantId); + if (created) { + ruleChain.setId(ruleChainId); + } + edgeCtx.getRuleChainService().saveRuleChain(ruleChain, true, false); + return Pair.of(created, isRoot); + } + + protected void saveOrUpdateRuleChainMetadata(TenantId tenantId, RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg) { + RuleChainMetaData ruleChainMetadata = JacksonUtil.fromString(ruleChainMetadataUpdateMsg.getEntity(), RuleChainMetaData.class, true); + if (ruleChainMetadata == null) { + throw new RuntimeException("[{" + tenantId + "}] ruleChainMetadataUpdateMsg {" + ruleChainMetadataUpdateMsg + "} cannot be converted to rule chain metadata"); + } + if (!ruleChainMetadata.getNodes().isEmpty()) { + ruleChainMetadata.setVersion(null); + for (RuleNode ruleNode : ruleChainMetadata.getNodes()) { + ruleNode.setRuleChainId(null); + ruleNode.setId(null); + } + edgeCtx.getRuleChainService().saveRuleChainMetaData(tenantId, ruleChainMetadata, Function.identity(), true); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainEdgeProcessor.java index 772300bded..06fb4c37a2 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainEdgeProcessor.java @@ -15,29 +15,123 @@ */ package org.thingsboard.server.service.edge.rpc.processor.rule; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EdgeUtils; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg; import org.thingsboard.server.gen.edge.v1.RuleChainUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; -import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +import java.util.UUID; import static org.thingsboard.server.dao.edge.EdgeServiceImpl.EDGE_IS_ROOT_BODY_KEY; @Slf4j @Component @TbCoreComponent -public class RuleChainEdgeProcessor extends BaseEdgeProcessor { +public class RuleChainEdgeProcessor extends BaseRuleChainProcessor { + + public ListenableFuture processRuleChainMsgFromEdge(TenantId tenantId, Edge edge, RuleChainUpdateMsg ruleChainUpdateMsg) { + log.trace("[{}] executing processRuleChainMsgFromEdge [{}] from edge [{}]", tenantId, ruleChainUpdateMsg, edge.getName()); + RuleChainId ruleChainId = new RuleChainId(new UUID(ruleChainUpdateMsg.getIdMSB(), ruleChainUpdateMsg.getIdLSB())); + try { + edgeSynchronizationManager.getEdgeId().set(edge.getId()); + + switch (ruleChainUpdateMsg.getMsgType()) { + case ENTITY_CREATED_RPC_MESSAGE: + case ENTITY_UPDATED_RPC_MESSAGE: + return saveOrUpdateRuleChain(tenantId, ruleChainId, ruleChainUpdateMsg, edge); + case ENTITY_DELETED_RPC_MESSAGE: + RuleChain ruleChainToDelete = edgeCtx.getRuleChainService().findRuleChainById(tenantId, ruleChainId); + if (ruleChainToDelete != null) { + edgeCtx.getRuleChainService().unassignRuleChainFromEdge(tenantId, ruleChainId, edge.getId(), false); + } + return Futures.immediateFuture(null); + case UNRECOGNIZED: + default: + return handleUnsupportedMsgType(ruleChainUpdateMsg.getMsgType()); + } + } catch (DataValidationException e) { + if (e.getMessage().contains("limit reached")) { + log.warn("[{}] Number of allowed rule chains violated {}", tenantId, ruleChainUpdateMsg, e); + return Futures.immediateFuture(null); + } else { + return Futures.immediateFailedFuture(e); + } + } finally { + edgeSynchronizationManager.getEdgeId().remove(); + } + } + + private ListenableFuture saveOrUpdateRuleChain(TenantId tenantId, RuleChainId ruleChainId, RuleChainUpdateMsg ruleChainUpdateMsg, Edge edge) { + try { + Pair resultPair = super.saveOrUpdateRuleChain(tenantId, ruleChainId, ruleChainUpdateMsg, RuleChainType.EDGE); + Boolean created = resultPair.getFirst(); + if (created) { + createRelationFromEdge(tenantId, edge.getId(), ruleChainId); + pushRuleChainCreatedEventToRuleEngine(tenantId, edge, ruleChainId, ruleChainUpdateMsg.getEntity()); + edgeCtx.getRuleChainService().assignRuleChainToEdge(tenantId, ruleChainId, edge.getId()); + } + Boolean isRoot = resultPair.getSecond(); + if (isRoot) { + edge = edgeCtx.getEdgeService().findEdgeById(tenantId, edge.getId()); + edgeCtx.getEdgeService().setEdgeRootRuleChain(tenantId, edge, ruleChainId); + } + } catch (Exception e) { + log.error("Failed to save or update rule chain", e); + return Futures.immediateFailedFuture(e); + } + return Futures.immediateFuture(null); + } + + private void pushRuleChainCreatedEventToRuleEngine(TenantId tenantId, Edge edge, RuleChainId ruleChainId, String ruleChainAsString) { + try { + TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, null); + pushEntityEventToRuleEngine(tenantId, ruleChainId, null, TbMsgType.ENTITY_CREATED, ruleChainAsString, msgMetaData); + } catch (Exception e) { + log.warn("[{}][{}] Failed to push rule chain action to rule engine: {}", tenantId, ruleChainId, TbMsgType.ENTITY_CREATED.name(), e); + } + } + + public ListenableFuture processRuleChainMetadataMsgFromEdge(TenantId tenantId, Edge edge, RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg) { + log.trace("[{}] executing processRuleChainMetadataMsgFromEdge [{}] from edge [{}]", tenantId, ruleChainMetadataUpdateMsg, edge.getName()); + try { + edgeSynchronizationManager.getEdgeId().set(edge.getId()); + + switch (ruleChainMetadataUpdateMsg.getMsgType()) { + case ENTITY_CREATED_RPC_MESSAGE: + case ENTITY_UPDATED_RPC_MESSAGE: + saveOrUpdateRuleChainMetadata(tenantId, ruleChainMetadataUpdateMsg); + return Futures.immediateFuture(null); + case UNRECOGNIZED: + default: + return handleUnsupportedMsgType(ruleChainMetadataUpdateMsg.getMsgType()); + } + } catch (Exception e) { + String errMsg = String.format("Can't process rule chain metadata update msg %s", ruleChainMetadataUpdateMsg); + log.error(errMsg, e); + return Futures.immediateFailedFuture(new RuntimeException(errMsg, e)); + } finally { + edgeSynchronizationManager.getEdgeId().remove(); + } + } @Override public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/DefaultEdgeRequestsService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/DefaultEdgeRequestsService.java index a371582409..6fc6d5bad9 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/DefaultEdgeRequestsService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/DefaultEdgeRequestsService.java @@ -152,8 +152,7 @@ public class DefaultEdgeRequestsService implements EdgeRequestsService { entityData = new HashMap<>(); attributes = JacksonUtil.newObjectNode(); for (AttributeKvEntry attr : ssAttributes) { - if (DefaultDeviceStateService.PERSISTENT_ATTRIBUTES.contains(attr.getKey()) - && !DefaultDeviceStateService.INACTIVITY_TIMEOUT.equals(attr.getKey())) { + if (DefaultDeviceStateService.ACTIVITY_KEYS_WITHOUT_INACTIVITY_TIMEOUT.contains(attr.getKey())) { continue; } if (attr.getDataType() == DataType.BOOLEAN && attr.getBooleanValue().isPresent()) { @@ -200,7 +199,7 @@ public class DefaultEdgeRequestsService implements EdgeRequestsService { } Map> tsData = new HashMap<>(); for (TsKvEntry tsKvEntry : tsKvEntries) { - if (DefaultDeviceStateService.PERSISTENT_ATTRIBUTES.contains(tsKvEntry.getKey())) { + if (DefaultDeviceStateService.ACTIVITY_KEYS_WITH_INACTIVITY_TIMEOUT.contains(tsKvEntry.getKey())) { continue; } tsData.computeIfAbsent(tsKvEntry.getTs(), k -> new HashMap<>()).put(tsKvEntry.getKey(), tsKvEntry.getValue()); diff --git a/application/src/main/java/org/thingsboard/server/service/edqs/DefaultEdqsApiService.java b/application/src/main/java/org/thingsboard/server/service/edqs/DefaultEdqsApiService.java new file mode 100644 index 0000000000..51c963ed2f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edqs/DefaultEdqsApiService.java @@ -0,0 +1,117 @@ +/** + * 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. + */ +package org.thingsboard.server.service.edqs; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.edqs.query.EdqsRequest; +import org.thingsboard.server.common.data.edqs.query.EdqsResponse; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.edqs.EdqsApiService; +import org.thingsboard.server.edqs.state.EdqsPartitionService; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.FromEdqsMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; +import org.thingsboard.server.queue.TbQueueRequestTemplate; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.provider.EdqsClientQueueFactory; + +import java.util.UUID; + +@Service +@Slf4j +@RequiredArgsConstructor +@ConditionalOnExpression("'${queue.edqs.api.supported:true}' == 'true' && ('${service.type:null}' == 'monolith' || '${service.type:null}' == 'tb-core')") +public class DefaultEdqsApiService implements EdqsApiService { + + private final EdqsPartitionService edqsPartitionService; + private final EdqsClientQueueFactory queueFactory; + private TbQueueRequestTemplate, TbProtoQueueMsg> requestTemplate; + + @Value("${queue.edqs.api.auto_enable:true}") + private boolean autoEnable; + + private Boolean apiEnabled = null; + + @PostConstruct + private void init() { + requestTemplate = queueFactory.createEdqsRequestTemplate(); + requestTemplate.init(); + } + + @Override + public ListenableFuture processRequest(TenantId tenantId, CustomerId customerId, EdqsRequest request) { + var requestMsg = ToEdqsMsg.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setTs(System.currentTimeMillis()) + .setRequestMsg(TransportProtos.EdqsRequestMsg.newBuilder() + .setValue(JacksonUtil.toString(request)) + .build()); + if (customerId != null && !customerId.isNullUid()) { + requestMsg.setCustomerIdMSB(customerId.getId().getMostSignificantBits()); + requestMsg.setCustomerIdLSB(customerId.getId().getLeastSignificantBits()); + } + + Integer partition = edqsPartitionService.resolvePartition(tenantId); + ListenableFuture> resultFuture = requestTemplate.send(new TbProtoQueueMsg<>(UUID.randomUUID(), requestMsg.build()), partition); + return Futures.transform(resultFuture, msg -> { + TransportProtos.EdqsResponseMsg responseMsg = msg.getValue().getResponseMsg(); + return JacksonUtil.fromString(responseMsg.getValue(), EdqsResponse.class); + }, MoreExecutors.directExecutor()); + } + + @Override + public boolean isEnabled() { + return Boolean.TRUE.equals(apiEnabled); + } + + @Override + public void setEnabled(boolean enabled) { + if (enabled) { + log.info("Enabling EDQS API"); + } else { + log.info("Disabling EDQS API"); + } + apiEnabled = enabled; + } + + @Override + public boolean isSupported() { + return true; + } + + @Override + public boolean isAutoEnable() { + return autoEnable; + } + + @PreDestroy + private void stop() { + requestTemplate.stop(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edqs/DefaultEdqsService.java b/application/src/main/java/org/thingsboard/server/service/edqs/DefaultEdqsService.java new file mode 100644 index 0000000000..e823dee4e7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edqs/DefaultEdqsService.java @@ -0,0 +1,298 @@ +/** + * 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. + */ +package org.thingsboard.server.service.edqs; + +import com.google.protobuf.ByteString; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.edqs.EdqsEventType; +import org.thingsboard.server.common.data.edqs.EdqsObject; +import org.thingsboard.server.common.data.edqs.EdqsSyncRequest; +import org.thingsboard.server.common.data.edqs.Entity; +import org.thingsboard.server.common.data.edqs.ToCoreEdqsMsg; +import org.thingsboard.server.common.data.edqs.ToCoreEdqsRequest; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.JsonDataEntry; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.msg.edqs.EdqsApiService; +import org.thingsboard.server.common.msg.edqs.EdqsService; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.edqs.processor.EdqsProducer; +import org.thingsboard.server.edqs.state.EdqsPartitionService; +import org.thingsboard.server.edqs.util.EdqsConverter; +import org.thingsboard.server.gen.transport.TransportProtos.EdqsEventMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsCoreServiceMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; +import org.thingsboard.server.queue.discovery.HashPartitionService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.discovery.TopicService; +import org.thingsboard.server.queue.edqs.EdqsQueue; +import org.thingsboard.server.queue.environment.DistributedLock; +import org.thingsboard.server.queue.environment.DistributedLockService; +import org.thingsboard.server.queue.provider.EdqsClientQueueFactory; +import org.thingsboard.server.queue.util.AfterStartUp; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +@Slf4j +@ConditionalOnProperty(value = "queue.edqs.sync.enabled", havingValue = "true") +public class DefaultEdqsService implements EdqsService { + + private final EdqsClientQueueFactory queueFactory; + private final EdqsConverter edqsConverter; + private final EdqsSyncService edqsSyncService; + private final EdqsApiService edqsApiService; + private final DistributedLockService distributedLockService; + private final AttributesService attributesService; + private final EdqsPartitionService edqsPartitionService; + private final TopicService topicService; + private final TbServiceInfoProvider serviceInfoProvider; + @Autowired @Lazy + private TbClusterService clusterService; + @Autowired @Lazy + private HashPartitionService hashPartitionService; + + private EdqsProducer eventsProducer; + private ExecutorService executor; + private DistributedLock syncLock; + + @PostConstruct + private void init() { + executor = ThingsBoardExecutors.newWorkStealingPool(12, getClass()); + eventsProducer = EdqsProducer.builder() + .queue(EdqsQueue.EVENTS) + .partitionService(edqsPartitionService) + .topicService(topicService) + .producer(queueFactory.createEdqsMsgProducer(EdqsQueue.EVENTS)) + .build(); + syncLock = distributedLockService.getLock("edqs_sync"); + } + + @AfterStartUp(order = AfterStartUp.REGULAR_SERVICE) + public void onStartUp() { + if (!serviceInfoProvider.isService(ServiceType.TB_CORE)) { + return; + } + executor.submit(() -> { + try { + EdqsSyncState syncState = getSyncState(); + if (edqsSyncService.isSyncNeeded() || syncState == null || syncState.getStatus() != EdqsSyncStatus.FINISHED) { + if (hashPartitionService.isSystemPartitionMine(ServiceType.TB_CORE)) { + processSystemRequest(ToCoreEdqsRequest.builder() + .syncRequest(new EdqsSyncRequest()) + .build()); + } + } else if (edqsApiService.isSupported() && edqsApiService.isAutoEnable()) { + // only if topic/RocksDB is not empty and sync is finished + edqsApiService.setEnabled(true); + } + } catch (Throwable e) { + log.error("Failed to start EDQS service", e); + } + }); + } + + @Override + public void processSystemRequest(ToCoreEdqsRequest request) { + log.info("Processing system request {}", request); + if (request.getSyncRequest() != null) { + saveSyncState(EdqsSyncStatus.REQUESTED); + } + broadcast(request.toInternalMsg()); + } + + @Override + public void processSystemMsg(ToCoreEdqsMsg msg) { + executor.submit(() -> { + log.info("Processing system msg {}", msg); + try { + if (msg.getApiEnabled() != null) { + edqsApiService.setEnabled(msg.getApiEnabled()); + } + + if (msg.getSyncRequest() != null) { + syncLock.lock(); + try { + EdqsSyncState syncState = getSyncState(); + if (syncState != null && syncState.getStatus() == EdqsSyncStatus.FINISHED) { + log.info("EDQS sync is already finished"); + return; + } + + saveSyncState(EdqsSyncStatus.STARTED); + edqsSyncService.sync(); + saveSyncState(EdqsSyncStatus.FINISHED); + + if (edqsApiService.isSupported()) + if (edqsApiService.isAutoEnable()) { + log.info("EDQS sync is finished, auto-enabling API"); + broadcast(ToCoreEdqsMsg.builder() + .apiEnabled(Boolean.TRUE) + .build()); + } else { + log.info("EDQS sync is finished, but leaving API disabled"); + } + } catch (Exception e) { + log.error("Failed to complete sync", e); + saveSyncState(EdqsSyncStatus.FAILED); + } finally { + syncLock.unlock(); + } + } + } catch (Throwable e) { + log.error("Failed to process msg {}", msg, e); + } + }); + } + + @Override + public void onUpdate(TenantId tenantId, EntityId entityId, Object entity) { + EntityType entityType = entityId.getEntityType(); + ObjectType objectType = ObjectType.fromEntityType(entityType); + if (!isEdqsType(tenantId, objectType)) { + log.trace("[{}][{}] Ignoring update event, type {} not supported", tenantId, entityId, entityType); + return; + } + onUpdate(tenantId, objectType, edqsConverter.toEntity(entityType, entity)); + } + + @Override + public void onUpdate(TenantId tenantId, ObjectType objectType, EdqsObject object) { + processEvent(tenantId, objectType, EdqsEventType.UPDATED, object); + } + + @Override + public void onDelete(TenantId tenantId, EntityId entityId) { + EntityType entityType = entityId.getEntityType(); + ObjectType objectType = ObjectType.fromEntityType(entityType); + if (!isEdqsType(tenantId, objectType)) { + log.trace("[{}][{}] Ignoring deletion event, type {} not supported", tenantId, entityId, entityType); + return; + } + onDelete(tenantId, objectType, new Entity(entityType, entityId.getId(), Long.MAX_VALUE)); + } + + @Override + public void onDelete(TenantId tenantId, ObjectType objectType, EdqsObject object) { + processEvent(tenantId, objectType, EdqsEventType.DELETED, object); + } + + protected void processEvent(TenantId tenantId, ObjectType objectType, EdqsEventType eventType, EdqsObject object) { + executor.submit(() -> { + try { + String key = object.key(); + Long version = object.version(); + EdqsEventMsg.Builder eventMsg = EdqsEventMsg.newBuilder() + .setKey(key) + .setObjectType(objectType.name()) + .setData(ByteString.copyFrom(edqsConverter.serialize(objectType, object))) + .setEventType(eventType.name()); + if (version != null) { + eventMsg.setVersion(version); + } + eventsProducer.send(tenantId, objectType, key, ToEdqsMsg.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setTs(System.currentTimeMillis()) + .setEventMsg(eventMsg) + .build()); + } catch (Throwable e) { + log.error("[{}] Failed to push {} event for {} {}", tenantId, eventType, objectType, object, e); + } + }); + } + + private boolean isEdqsType(TenantId tenantId, ObjectType objectType) { + if (objectType == null) { + return false; + } + if (!tenantId.isSysTenantId()) { + return ObjectType.edqsTypes.contains(objectType); + } else { + return ObjectType.edqsSystemTypes.contains(objectType); + } + } + + private void broadcast(ToCoreEdqsMsg msg) { + clusterService.broadcastToCore(ToCoreNotificationMsg.newBuilder() + .setToEdqsCoreServiceMsg(ToEdqsCoreServiceMsg.newBuilder() + .setValue(ByteString.copyFrom(JacksonUtil.writeValueAsBytes(msg)))) + .build()); + } + + @SneakyThrows + private EdqsSyncState getSyncState() { + EdqsSyncState state = attributesService.find(TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID, AttributeScope.SERVER_SCOPE, "edqsSyncState").get(30, TimeUnit.SECONDS) + .flatMap(KvEntry::getJsonValue) + .map(value -> JacksonUtil.fromString(value, EdqsSyncState.class)) + .orElse(null); + log.info("EDQS sync state: {}", state); + return state; + } + + @SneakyThrows + private void saveSyncState(EdqsSyncStatus status) { + EdqsSyncState state = new EdqsSyncState(status); + log.info("New EDQS sync state: {}", state); + attributesService.save(TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID, AttributeScope.SERVER_SCOPE, new BaseAttributeKvEntry( + new JsonDataEntry("edqsSyncState", JacksonUtil.toString(state)), + System.currentTimeMillis())).get(30, TimeUnit.SECONDS); + } + + @PreDestroy + private void stop() { + executor.shutdown(); + eventsProducer.stop(); + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + private static class EdqsSyncState { + private EdqsSyncStatus status; + } + + private enum EdqsSyncStatus { + REQUESTED, + STARTED, + FINISHED, + FAILED + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edqs/EdqsListener.java b/application/src/main/java/org/thingsboard/server/service/edqs/EdqsListener.java new file mode 100644 index 0000000000..d77df5ced8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edqs/EdqsListener.java @@ -0,0 +1,61 @@ +/** + * 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. + */ +package org.thingsboard.server.service.edqs; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionalEventListener; +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.msg.edqs.EdqsService; +import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; +import org.thingsboard.server.dao.eventsourcing.RelationActionEvent; +import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; + +@Service +@RequiredArgsConstructor +@ConditionalOnProperty(value = "queue.edqs.sync.enabled", havingValue = "true") +public class EdqsListener { + + private final EdqsService edqsService; + + @TransactionalEventListener(fallbackExecution = true) + public void onUpdate(SaveEntityEvent event) { + if (event.getEntityId() == null || event.getEntity() == null) { + return; + } + edqsService.onUpdate(event.getTenantId(), event.getEntityId(), event.getEntity()); + } + + @TransactionalEventListener(fallbackExecution = true) + public void onDelete(DeleteEntityEvent event) { + if (event.getEntityId() == null) { + return; + } + edqsService.onDelete(event.getTenantId(), event.getEntityId()); + } + + @TransactionalEventListener(fallbackExecution = true) + public void handleEvent(RelationActionEvent relationEvent) { + if (relationEvent.getActionType() == ActionType.RELATION_ADD_OR_UPDATE) { + edqsService.onUpdate(relationEvent.getTenantId(), ObjectType.RELATION, relationEvent.getRelation()); + } else if (relationEvent.getActionType() == ActionType.RELATION_DELETED) { + edqsService.onDelete(relationEvent.getTenantId(), ObjectType.RELATION, relationEvent.getRelation()); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edqs/EdqsSyncService.java b/application/src/main/java/org/thingsboard/server/service/edqs/EdqsSyncService.java new file mode 100644 index 0000000000..79e0e60983 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edqs/EdqsSyncService.java @@ -0,0 +1,284 @@ +/** + * 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. + */ +package org.thingsboard.server.service.edqs; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.edqs.AttributeKv; +import org.thingsboard.server.common.data.edqs.EdqsEventType; +import org.thingsboard.server.common.data.edqs.EdqsObject; +import org.thingsboard.server.common.data.edqs.Entity; +import org.thingsboard.server.common.data.edqs.LatestTsKv; +import org.thingsboard.server.common.data.edqs.fields.EntityFields; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageDataIterable; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.dao.Dao; +import org.thingsboard.server.dao.attributes.AttributesDao; +import org.thingsboard.server.dao.dictionary.KeyDictionaryDao; +import org.thingsboard.server.dao.entity.EntityDaoRegistry; +import org.thingsboard.server.dao.model.sql.AttributeKvEntity; +import org.thingsboard.server.dao.model.sql.RelationEntity; +import org.thingsboard.server.dao.model.sqlts.dictionary.KeyDictionaryEntry; +import org.thingsboard.server.dao.model.sqlts.latest.TsKvLatestEntity; +import org.thingsboard.server.dao.sql.relation.RelationRepository; +import org.thingsboard.server.dao.sqlts.latest.TsKvLatestRepository; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.thingsboard.server.common.data.ObjectType.ATTRIBUTE_KV; +import static org.thingsboard.server.common.data.ObjectType.LATEST_TS_KV; +import static org.thingsboard.server.common.data.ObjectType.RELATION; +import static org.thingsboard.server.common.data.ObjectType.edqsTenantTypes; + +@Slf4j +public abstract class EdqsSyncService { + + @Value("${queue.edqs.sync.entity_batch_size:10000}") + private int entityBatchSize; + @Value("${queue.edqs.sync.ts_batch_size:10000}") + private int tsBatchSize; + @Autowired + private EntityDaoRegistry entityDaoRegistry; + @Autowired + private AttributesDao attributesDao; + @Autowired + private KeyDictionaryDao keyDictionaryDao; + @Autowired + private RelationRepository relationRepository; + @Autowired + private TsKvLatestRepository tsKvLatestRepository; + @Autowired + @Lazy + private DefaultEdqsService edqsService; + + private final ConcurrentHashMap entityInfoMap = new ConcurrentHashMap<>(); + private final ConcurrentHashMap keys = new ConcurrentHashMap<>(); + + private final Map counters = new ConcurrentHashMap<>(); + + public abstract boolean isSyncNeeded(); + + public void sync() { + log.info("Synchronizing data to EDQS"); + long startTs = System.currentTimeMillis(); + counters.clear(); + + syncTenantEntities(); + syncRelations(); + loadKeyDictionary(); + syncAttributes(); + syncLatestTimeseries(); + + counters.clear(); + log.info("Finishing synchronizing data to EDQS in {} ms", (System.currentTimeMillis() - startTs)); + } + + private void process(TenantId tenantId, ObjectType type, EdqsObject object) { + AtomicInteger counter = counters.computeIfAbsent(type, t -> new AtomicInteger()); + if (counter.incrementAndGet() % 10000 == 0) { + log.info("Processed {} {} objects", counter.get(), type); + } + edqsService.processEvent(tenantId, type, EdqsEventType.UPDATED, object); + } + + private void syncTenantEntities() { + for (ObjectType type : edqsTenantTypes) { + log.info("Synchronizing {} entities to EDQS", type); + long ts = System.currentTimeMillis(); + EntityType entityType = type.toEntityType(); + Dao dao = entityDaoRegistry.getDao(entityType); + UUID lastId = UUID.fromString("00000000-0000-0000-0000-000000000000"); + while (true) { + var batch = dao.findNextBatch(lastId, entityBatchSize); + if (batch.isEmpty()) { + break; + } + for (EntityFields entityFields : batch) { + TenantId tenantId = TenantId.fromUUID(entityFields.getTenantId()); + entityInfoMap.put(entityFields.getId(), new EntityIdInfo(entityType, tenantId)); + process(tenantId, type, new Entity(entityType, entityFields)); + } + EntityFields lastRecord = batch.get(batch.size() - 1); + lastId = lastRecord.getId(); + } + log.info("Finished synchronizing {} entities to EDQS in {} ms", type, (System.currentTimeMillis() - ts)); + } + } + + private void syncRelations() { + log.info("Synchronizing relations to EDQS"); + long ts = System.currentTimeMillis(); + UUID lastFromEntityId = UUID.fromString("00000000-0000-0000-0000-000000000000"); + String lastFromEntityType = ""; + String lastRelationTypeGroup = ""; + String lastRelationType = ""; + UUID lastToEntityId = UUID.fromString("00000000-0000-0000-0000-000000000000"); + String lastToEntityType = ""; + + while (true) { + List batch = relationRepository.findNextBatch(lastFromEntityId, lastFromEntityType, lastRelationTypeGroup, + lastRelationType, lastToEntityId, lastToEntityType, entityBatchSize); + if (batch.isEmpty()) { + break; + } + processRelationBatch(batch); + + RelationEntity lastRecord = batch.get(batch.size() - 1); + lastFromEntityId = lastRecord.getFromId(); + lastFromEntityType = lastRecord.getFromType(); + lastRelationTypeGroup = lastRecord.getRelationTypeGroup(); + lastRelationType = lastRecord.getRelationType(); + lastToEntityId = lastRecord.getToId(); + lastToEntityType = lastRecord.getToType(); + } + log.info("Finished synchronizing relations to EDQS in {} ms", (System.currentTimeMillis() - ts)); + } + + private void processRelationBatch(List relations) { + for (RelationEntity relation : relations) { + if (RelationTypeGroup.COMMON.name().equals(relation.getRelationTypeGroup())) { + EntityIdInfo entityIdInfo = entityInfoMap.get(relation.getFromId()); + if (entityIdInfo != null) { + process(entityIdInfo.tenantId(), RELATION, relation.toData()); + } else { + log.info("Relation from id not found: {} ", relation); + } + } + } + } + + private void loadKeyDictionary() { + log.info("Loading key dictionary"); + long ts = System.currentTimeMillis(); + var keyDictionaryEntries = new PageDataIterable<>(keyDictionaryDao::findAll, 10000); + for (KeyDictionaryEntry keyDictionaryEntry : keyDictionaryEntries) { + keys.put(keyDictionaryEntry.getKeyId(), keyDictionaryEntry.getKey()); + } + log.info("Finished loading key dictionary in {} ms", (System.currentTimeMillis() - ts)); + } + + private void syncAttributes() { + log.info("Synchronizing attributes to EDQS"); + long ts = System.currentTimeMillis(); + + UUID lastEntityId = UUID.fromString("00000000-0000-0000-0000-000000000000"); + int lastAttributeType = Integer.MIN_VALUE; + int lastAttributeKey = Integer.MIN_VALUE; + + while (true) { + List batch = attributesDao.findNextBatch(lastEntityId, lastAttributeType, lastAttributeKey, tsBatchSize); + if (batch.isEmpty()) { + break; + } + processAttributeBatch(batch); + + AttributeKvEntity lastRecord = batch.get(batch.size() - 1); + lastEntityId = lastRecord.getId().getEntityId(); + lastAttributeType = lastRecord.getId().getAttributeType(); + lastAttributeKey = lastRecord.getId().getAttributeKey(); + } + log.info("Finished synchronizing attributes to EDQS in {} ms", (System.currentTimeMillis() - ts)); + } + + private void processAttributeBatch(List batch) { + for (AttributeKvEntity attribute : batch) { + attribute.setStrKey(getStrKeyOrFetchFromDb(attribute.getId().getAttributeKey())); + UUID entityId = attribute.getId().getEntityId(); + EntityIdInfo entityIdInfo = entityInfoMap.get(entityId); + if (entityIdInfo == null) { + log.debug("Skipping attribute with entity UUID {} as it is not found in entityInfoMap", entityId); + continue; + } + AttributeKv attributeKv = new AttributeKv( + EntityIdFactory.getByTypeAndUuid(entityIdInfo.entityType(), entityId), + AttributeScope.valueOf(attribute.getId().getAttributeType()), + attribute.toData(), + attribute.getVersion()); + process(entityIdInfo.tenantId(), ATTRIBUTE_KV, attributeKv); + } + } + + private void syncLatestTimeseries() { + log.info("Synchronizing latest timeseries to EDQS"); + long ts = System.currentTimeMillis(); + UUID lastEntityId = UUID.fromString("00000000-0000-0000-0000-000000000000"); + int lastKey = Integer.MIN_VALUE; + + while (true) { + List batch = tsKvLatestRepository.findNextBatch(lastEntityId, lastKey, tsBatchSize); + if (batch.isEmpty()) { + break; + } + processTsKvLatestBatch(batch); + + TsKvLatestEntity lastRecord = batch.get(batch.size() - 1); + lastEntityId = lastRecord.getEntityId(); + lastKey = lastRecord.getKey(); + } + log.info("Finished synchronizing latest timeseries to EDQS in {} ms", (System.currentTimeMillis() - ts)); + } + + private void processTsKvLatestBatch(List tsKvLatestEntities) { + for (TsKvLatestEntity tsKvLatestEntity : tsKvLatestEntities) { + try { + String strKey = getStrKeyOrFetchFromDb(tsKvLatestEntity.getKey()); + if (strKey == null) { + log.debug("Skipping latest timeseries with key {} as it is not found in key dictionary", tsKvLatestEntity.getKey()); + continue; + } + tsKvLatestEntity.setStrKey(strKey); + UUID entityUuid = tsKvLatestEntity.getEntityId(); + EntityIdInfo entityIdInfo = entityInfoMap.get(entityUuid); + if (entityIdInfo != null) { + EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityIdInfo.entityType(), entityUuid); + LatestTsKv latestTsKv = new LatestTsKv(entityId, tsKvLatestEntity.toData(), tsKvLatestEntity.getVersion()); + process(entityIdInfo.tenantId(), LATEST_TS_KV, latestTsKv); + } + } catch (Exception e) { + log.error("Failed to sync latest timeseries: {}", tsKvLatestEntity, e); + } + } + } + + private String getStrKeyOrFetchFromDb(int key) { + String strKey = keys.get(key); + if (strKey != null) { + return strKey; + } else { + strKey = keyDictionaryDao.getKey(key); + if (strKey != null) { + keys.put(key, strKey); + } + } + return strKey; + } + + public record EntityIdInfo(EntityType entityType, TenantId tenantId) { + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edqs/KafkaEdqsSyncService.java b/application/src/main/java/org/thingsboard/server/service/edqs/KafkaEdqsSyncService.java new file mode 100644 index 0000000000..4ef552521b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edqs/KafkaEdqsSyncService.java @@ -0,0 +1,42 @@ +/** + * 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. + */ +package org.thingsboard.server.service.edqs; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Service; +import org.thingsboard.server.queue.edqs.EdqsQueue; +import org.thingsboard.server.queue.kafka.TbKafkaAdmin; +import org.thingsboard.server.queue.kafka.TbKafkaSettings; + +import java.util.Collections; + +@Service +@ConditionalOnExpression("'${queue.edqs.sync.enabled:true}' == 'true' && '${queue.type:null}' == 'kafka'") +public class KafkaEdqsSyncService extends EdqsSyncService { + + private final boolean syncNeeded; + + public KafkaEdqsSyncService(TbKafkaSettings kafkaSettings) { + TbKafkaAdmin kafkaAdmin = new TbKafkaAdmin(kafkaSettings, Collections.emptyMap()); + this.syncNeeded = kafkaAdmin.isTopicEmpty(EdqsQueue.EVENTS.getTopic()); + } + + @Override + public boolean isSyncNeeded() { + return syncNeeded; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edqs/LocalEdqsSyncService.java b/application/src/main/java/org/thingsboard/server/service/edqs/LocalEdqsSyncService.java new file mode 100644 index 0000000000..904391f172 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edqs/LocalEdqsSyncService.java @@ -0,0 +1,35 @@ +/** + * 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. + */ +package org.thingsboard.server.service.edqs; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Service; +import org.thingsboard.server.edqs.util.EdqsRocksDb; + +@Service +@RequiredArgsConstructor +@ConditionalOnExpression("'${queue.edqs.sync.enabled:true}' == 'true' && '${queue.type:null}' == 'in-memory'") +public class LocalEdqsSyncService extends EdqsSyncService { + + private final EdqsRocksDb db; + + @Override + public boolean isSyncNeeded() { + return db.isNew(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java index aff013ca2b..e9ee7202a7 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java @@ -31,9 +31,15 @@ import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; import org.thingsboard.server.dao.alarm.AlarmService; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.edge.EdgeService; +import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.service.executors.DbCallbackExecutorService; import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; @@ -71,6 +77,8 @@ public abstract class AbstractTbEntityService { @Autowired(required = false) @Lazy private EntitiesVersionControlService vcService; + @Autowired + protected EntityService entityService; protected boolean isTestProfile() { return Set.of(this.env.getActiveProfiles()).contains("test"); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java index 35aad36dff..648e89adc9 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java @@ -31,7 +31,9 @@ import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.id.DeviceId; @@ -50,10 +52,12 @@ import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg; import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.msg.rule.engine.DeviceCredentialsUpdateNotificationMsg; +import org.thingsboard.server.dao.edge.EdgeSynchronizationManager; import org.thingsboard.server.dao.eventsourcing.ActionEntityEvent; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.queue.TbQueueCallback; import java.util.Set; @@ -64,6 +68,7 @@ public class EntityStateSourcingListener { private final TenantService tenantService; private final TbClusterService tbClusterService; + private final EdgeSynchronizationManager edgeSynchronizationManager; @PostConstruct public void init() { @@ -72,6 +77,11 @@ public class EntityStateSourcingListener { @TransactionalEventListener(fallbackExecution = true) public void handleEvent(SaveEntityEvent event) { + if (Boolean.FALSE.equals(event.getBroadcastEvent())) { + log.trace("Ignoring event {}", event); + return; + } + TenantId tenantId = event.getTenantId(); EntityId entityId = event.getEntityId(); if (entityId == null) { @@ -83,7 +93,10 @@ public class EntityStateSourcingListener { ComponentLifecycleEvent lifecycleEvent = isCreated ? ComponentLifecycleEvent.CREATED : ComponentLifecycleEvent.UPDATED; switch (entityType) { - case ASSET, ASSET_PROFILE, ENTITY_VIEW, NOTIFICATION_RULE -> { + case ASSET -> { + onAssetUpdate(event.getEntity(), event.getOldEntity()); + } + case ASSET_PROFILE, ENTITY_VIEW, NOTIFICATION_RULE -> { tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, lifecycleEvent); } case RULE_CHAIN -> { @@ -118,7 +131,11 @@ public class EntityStateSourcingListener { ApiUsageState apiUsageState = (ApiUsageState) event.getEntity(); tbClusterService.onApiStateChange(apiUsageState, null); } - default -> {} + case CALCULATED_FIELD -> { + onCalculatedFieldUpdate(event.getEntity(), event.getOldEntity()); + } + default -> { + } } } @@ -130,14 +147,18 @@ public class EntityStateSourcingListener { return; } EntityType entityType = entityId.getEntityType(); - if (!tenantId.isSysTenantId() && entityType != EntityType.TENANT && !tenantService.tenantExists(tenantId)) { + if (!tenantId.isSysTenantId() && entityType != EntityType.TENANT && !tenantService.tenantExists(tenantId)) { log.debug("[{}] Ignoring DeleteEntityEvent because tenant does not exist: {}", tenantId, event); return; } log.debug("[{}][{}][{}] Handling entity deletion event: {}", tenantId, entityType, entityId, event); switch (entityType) { - case ASSET, ASSET_PROFILE, EDGE, ENTITY_VIEW, CUSTOMER, NOTIFICATION_RULE -> { + case ASSET -> { + Asset asset = (Asset) event.getEntity(); + tbClusterService.onAssetDeleted(tenantId, asset, null); + } + case ASSET_PROFILE, ENTITY_VIEW, CUSTOMER, EDGE, NOTIFICATION_RULE -> { tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, ComponentLifecycleEvent.DELETED); } case NOTIFICATION_REQUEST -> { @@ -149,7 +170,8 @@ public class EntityStateSourcingListener { case RULE_CHAIN -> { RuleChain ruleChain = (RuleChain) event.getEntity(); if (RuleChainType.CORE.equals(ruleChain.getType())) { - Set referencingRuleChainIds = JacksonUtil.fromString(event.getBody(), new TypeReference<>() {}); + Set referencingRuleChainIds = JacksonUtil.fromString(event.getBody(), new TypeReference<>() { + }); if (referencingRuleChainIds != null) { referencingRuleChainIds.forEach(referencingRuleChainId -> tbClusterService.broadcastEntityStateChangeEvent(tenantId, referencingRuleChainId, ComponentLifecycleEvent.UPDATED)); @@ -163,11 +185,11 @@ public class EntityStateSourcingListener { } case TENANT_PROFILE -> { TenantProfile tenantProfile = (TenantProfile) event.getEntity(); - tbClusterService.onTenantProfileDelete(tenantProfile, null); + tbClusterService.onTenantProfileDelete(tenantProfile, TbQueueCallback.EMPTY); } case DEVICE -> { Device device = (Device) event.getEntity(); - tbClusterService.onDeviceDeleted(tenantId, device, null); + tbClusterService.onDeviceDeleted(tenantId, device, TbQueueCallback.EMPTY); } case DEVICE_PROFILE -> { DeviceProfile deviceProfile = (DeviceProfile) event.getEntity(); @@ -175,9 +197,14 @@ public class EntityStateSourcingListener { } case TB_RESOURCE -> { TbResourceInfo tbResource = (TbResourceInfo) event.getEntity(); - tbClusterService.onResourceDeleted(tbResource, null); + tbClusterService.onResourceDeleted(tbResource, TbQueueCallback.EMPTY); + } + case CALCULATED_FIELD -> { + CalculatedField calculatedField = (CalculatedField) event.getEntity(); + tbClusterService.onCalculatedFieldDeleted(calculatedField, TbQueueCallback.EMPTY); + } + default -> { } - default -> {} } } @@ -239,14 +266,35 @@ public class EntityStateSourcingListener { tbClusterService.onDeviceUpdated(device, oldDevice); } + private void onAssetUpdate(Object entity, Object oldEntity) { + Asset asset = (Asset) entity; + Asset oldAsset = null; + if (oldEntity instanceof Asset) { + oldAsset = (Asset) oldEntity; + } + tbClusterService.onAssetUpdated(asset, oldAsset); + } + private void onEdgeEvent(TenantId tenantId, EntityId entityId, Object entity, ComponentLifecycleEvent lifecycleEvent) { if (entity instanceof Edge) { + if (entityId.equals(edgeSynchronizationManager.getEdgeId().get())) { + return; + } tbClusterService.onEdgeStateChangeEvent(new ComponentLifecycleMsg(tenantId, entityId, lifecycleEvent)); } else if (entity instanceof EdgeEvent edgeEvent) { tbClusterService.onEdgeEventUpdate(new EdgeEventUpdateMsg(tenantId, edgeEvent.getEdgeId())); } } + private void onCalculatedFieldUpdate(Object entity, Object oldEntity) { + CalculatedField calculatedField = (CalculatedField) entity; + CalculatedField oldCalculatedField = null; + if (oldEntity instanceof CalculatedField) { + oldCalculatedField = (CalculatedField) oldEntity; + } + tbClusterService.onCalculatedFieldUpdated(calculatedField, oldCalculatedField, TbQueueCallback.EMPTY); + } + private void pushAssignedFromNotification(Tenant currentTenant, TenantId newTenantId, Device assignedDevice) { String data = JacksonUtil.toString(JacksonUtil.valueToTree(assignedDevice)); if (data != null) { diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java new file mode 100644 index 0000000000..4dfaec91cf --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java @@ -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. + */ +package org.thingsboard.server.service.entitiy.cf; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.cf.CalculatedFieldService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; +import org.thingsboard.server.service.security.model.SecurityUser; + +import java.util.Optional; + +@TbCoreComponent +@Service +@Slf4j +@RequiredArgsConstructor +public class DefaultTbCalculatedFieldService extends AbstractTbEntityService implements TbCalculatedFieldService { + + private final CalculatedFieldService calculatedFieldService; + + @Override + public CalculatedField save(CalculatedField calculatedField, SecurityUser user) throws ThingsboardException { + ActionType actionType = calculatedField.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + TenantId tenantId = calculatedField.getTenantId(); + try { + if (ActionType.UPDATED.equals(actionType)) { + CalculatedField existingCf = calculatedFieldService.findById(tenantId, calculatedField.getId()); + checkForEntityChange(existingCf, calculatedField); + } + checkEntityExistence(tenantId, calculatedField.getEntityId()); + CalculatedField savedCalculatedField = checkNotNull(calculatedFieldService.save(calculatedField)); + logEntityActionService.logEntityAction(tenantId, savedCalculatedField.getId(), savedCalculatedField, actionType, user); + return savedCalculatedField; + } catch (ThingsboardException e) { + logEntityActionService.logEntityAction(tenantId, emptyId(EntityType.CALCULATED_FIELD), calculatedField, actionType, user, e); + throw e; + } + } + + @Override + public CalculatedField findById(CalculatedFieldId calculatedFieldId, SecurityUser user) { + return calculatedFieldService.findById(user.getTenantId(), calculatedFieldId); + } + + @Override + public PageData findAllByTenantIdAndEntityId(EntityId entityId, SecurityUser user, PageLink pageLink) { + TenantId tenantId = user.getTenantId(); + checkEntityExistence(tenantId, entityId); + return calculatedFieldService.findAllCalculatedFieldsByEntityId(tenantId, entityId, pageLink); + } + + @Override + @Transactional + public void delete(CalculatedField calculatedField, SecurityUser user) { + ActionType actionType = ActionType.DELETED; + TenantId tenantId = calculatedField.getTenantId(); + CalculatedFieldId calculatedFieldId = calculatedField.getId(); + try { + calculatedFieldService.deleteCalculatedField(tenantId, calculatedFieldId); + logEntityActionService.logEntityAction(tenantId, calculatedFieldId, calculatedField, actionType, user, calculatedFieldId.toString()); + } catch (Exception e) { + logEntityActionService.logEntityAction(tenantId, emptyId(EntityType.CALCULATED_FIELD), actionType, user, e, calculatedFieldId.toString()); + throw e; + } + } + + private void checkForEntityChange(CalculatedField oldCalculatedField, CalculatedField newCalculatedField) { + if (!oldCalculatedField.getEntityId().equals(newCalculatedField.getEntityId())) { + throw new IllegalArgumentException("Changing the calculated field target entity after initialization is prohibited."); + } + } + + private void checkEntityExistence(TenantId tenantId, EntityId entityId) { + switch (entityId.getEntityType()) { + case ASSET, DEVICE, ASSET_PROFILE, DEVICE_PROFILE -> Optional.ofNullable(entityService.fetchEntity(tenantId, entityId)) + .orElseThrow(() -> new IllegalArgumentException(entityId.getEntityType().getNormalName() + " with id [" + entityId.getId() + "] does not exist.")); + default -> throw new IllegalArgumentException("Entity type '" + entityId.getEntityType() + "' does not support calculated fields."); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java new file mode 100644 index 0000000000..1e04a14a08 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java @@ -0,0 +1,36 @@ +/** + * 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. + */ +package org.thingsboard.server.service.entitiy.cf; + +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.service.security.model.SecurityUser; + +public interface TbCalculatedFieldService { + + CalculatedField save(CalculatedField calculatedField, SecurityUser user) throws ThingsboardException; + + CalculatedField findById(CalculatedFieldId calculatedFieldId, SecurityUser user); + + PageData findAllByTenantIdAndEntityId(EntityId entityId, SecurityUser user, PageLink pageLink); + + void delete(CalculatedField calculatedField, SecurityUser user); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/CalculatedFieldsDeletionTaskProcessor.java b/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/CalculatedFieldsDeletionTaskProcessor.java new file mode 100644 index 0000000000..bce7250bf7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/CalculatedFieldsDeletionTaskProcessor.java @@ -0,0 +1,43 @@ +/** + * 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. + */ +package org.thingsboard.server.service.housekeeper.processor; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.housekeeper.HousekeeperTask; +import org.thingsboard.server.common.data.housekeeper.HousekeeperTaskType; +import org.thingsboard.server.dao.cf.CalculatedFieldService; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CalculatedFieldsDeletionTaskProcessor extends HousekeeperTaskProcessor { + + private final CalculatedFieldService calculatedFieldService; + + @Override + public void process(HousekeeperTask task) throws Exception { + int deletedCount = calculatedFieldService.deleteAllCalculatedFieldsByEntityId(task.getTenantId(), task.getEntityId()); + log.debug("[{}][{}][{}] Deleted {} calculated fields", task.getTenantId(), task.getEntityId().getEntityType(), task.getEntityId(), deletedCount); + } + + @Override + public HousekeeperTaskType getTaskType() { + return HousekeeperTaskType.DELETE_CALCULATED_FIELDS; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java index 0f4cf30fac..a0012a6fb3 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java @@ -32,7 +32,7 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti // This list should include all versions which are compatible for the upgrade. // The compatibility cycle usually breaks when we have some scripts written in Java that may not work after new release. - private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("3.9.0"); + private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("3.9.0", "3.9.1"); private final ProjectInfo projectInfo; private final JdbcTemplate jdbcTemplate; diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java index b2cde03931..870ce3838b 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java @@ -69,6 +69,7 @@ import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.BooleanDataEntry; import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.mobile.app.MobileApp; import org.thingsboard.server.common.data.page.PageDataIterable; import org.thingsboard.server.common.data.page.PageLink; @@ -98,9 +99,9 @@ import org.thingsboard.server.dao.device.DeviceCredentialsService; import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.mobile.MobileAppDao; import org.thingsboard.server.dao.notification.NotificationSettingsService; import org.thingsboard.server.dao.notification.NotificationTargetService; -import org.thingsboard.server.dao.mobile.MobileAppDao; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.settings.AdminSettingsService; @@ -308,7 +309,7 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { jwtSettingsService.saveJwtSettings(jwtSettings); } - List mobiles = mobileAppDao.findByTenantId(TenantId.SYS_TENANT_ID, null, new PageLink(Integer.MAX_VALUE,0)).getData(); + List mobiles = mobileAppDao.findByTenantId(TenantId.SYS_TENANT_ID, null, new PageLink(Integer.MAX_VALUE, 0)).getData(); if (CollectionUtils.isNotEmpty(mobiles)) { mobiles.stream() .filter(mobileApp -> !validateKeyLength(mobileApp.getAppSecret())) @@ -571,7 +572,7 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { private void save(DeviceId deviceId, String key, boolean value) { if (persistActivityToTelemetry) { - ListenableFuture saveFuture = tsService.save( + ListenableFuture saveFuture = tsService.save( TenantId.SYS_TENANT_ID, deviceId, Collections.singletonList(new BasicTsKvEntry(System.currentTimeMillis(), new BooleanDataEntry(key, value))), 0L); diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java new file mode 100644 index 0000000000..ac62576714 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java @@ -0,0 +1,262 @@ +/** + * 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. + */ +package org.thingsboard.server.service.queue; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.calculatedField.CalculatedFieldLinkedTelemetryMsg; +import org.thingsboard.server.actors.calculatedField.CalculatedFieldTelemetryMsg; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.queue.QueueConfig; +import org.thingsboard.server.common.msg.cf.CalculatedFieldPartitionChangeMsg; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldLinkedTelemetryMsgProto; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; +import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory; +import org.thingsboard.server.queue.util.TbRuleEngineComponent; +import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.service.cf.CalculatedFieldCache; +import org.thingsboard.server.service.cf.CalculatedFieldStateService; +import org.thingsboard.server.service.profile.TbAssetProfileCache; +import org.thingsboard.server.service.profile.TbDeviceProfileCache; +import org.thingsboard.server.service.queue.processing.AbstractConsumerService; +import org.thingsboard.server.service.queue.processing.IdMsgPair; +import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService; + +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +@Service +@TbRuleEngineComponent +@Slf4j +public class DefaultTbCalculatedFieldConsumerService extends AbstractConsumerService implements TbCalculatedFieldConsumerService { + + @Value("${queue.calculated_fields.poll_interval:25}") + private long pollInterval; + @Value("${queue.calculated_fields.pack_processing_timeout:60000}") + private long packProcessingTimeout; + + private final TbRuleEngineQueueFactory queueFactory; + private final CalculatedFieldStateService stateService; + + public DefaultTbCalculatedFieldConsumerService(TbRuleEngineQueueFactory tbQueueFactory, + ActorSystemContext actorContext, + TbDeviceProfileCache deviceProfileCache, + TbAssetProfileCache assetProfileCache, + TbTenantProfileCache tenantProfileCache, + TbApiUsageStateService apiUsageStateService, + PartitionService partitionService, + ApplicationEventPublisher eventPublisher, + JwtSettingsService jwtSettingsService, + CalculatedFieldCache calculatedFieldCache, + CalculatedFieldStateService stateService) { + super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, calculatedFieldCache, apiUsageStateService, partitionService, + eventPublisher, jwtSettingsService); + this.queueFactory = tbQueueFactory; + this.stateService = stateService; + } + + @PostConstruct + public void init() { + super.init("tb-cf"); + + var queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, DataConstants.CF_QUEUE_NAME); + PartitionedQueueConsumerManager> eventConsumer = PartitionedQueueConsumerManager.>create() + .queueKey(queueKey) + .topic(partitionService.getTopic(queueKey)) + .pollInterval(pollInterval) + .msgPackProcessor(this::processMsgs) + .consumerCreator((config, partitionId) -> queueFactory.createToCalculatedFieldMsgConsumer()) + .queueAdmin(queueFactory.getCalculatedFieldQueueAdmin()) + .consumerExecutor(consumersExecutor) + .scheduler(scheduler) + .taskExecutor(mgmtExecutor) + .build(); + stateService.init(eventConsumer); + } + + @PreDestroy + public void destroy() { + super.destroy(); + } + + @Override + protected void startConsumers() { + super.startConsumers(); + } + + @Override + protected void onTbApplicationEvent(PartitionChangeEvent event) { + try { + event.getNewPartitions().forEach((queueKey, partitions) -> { + if (queueKey.getQueueName().equals(DataConstants.CF_QUEUE_NAME)) { + stateService.restore(queueKey, partitions); + } + }); + // eventConsumer's partitions will be updated by stateService + + // Cleanup old entities after corresponding consumers are stopped. + // Any periodic tasks need to check that the entity is still managed by the current server before processing. + actorContext.tell(new CalculatedFieldPartitionChangeMsg()); + } catch (Throwable t) { + log.error("Failed to process partition change event: {}", event, t); + } + } + + private void processMsgs(List> msgs, TbQueueConsumer> consumer, QueueConfig config) throws Exception { + List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); + ConcurrentMap> pendingMap = orderedMsgList.stream().collect( + Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); + CountDownLatch processingTimeoutLatch = new CountDownLatch(1); + TbPackProcessingContext> ctx = new TbPackProcessingContext<>( + processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); + PendingMsgHolder pendingMsgHolder = new PendingMsgHolder<>(); + Future packSubmitFuture = consumersExecutor.submit(() -> { + orderedMsgList.forEach((element) -> { + UUID id = element.getUuid(); + TbProtoQueueMsg msg = element.getMsg(); + log.trace("[{}] Creating main callback for message: {}", id, msg.getValue()); + TbCallback callback = new TbPackCallback<>(id, ctx); + try { + ToCalculatedFieldMsg toCfMsg = msg.getValue(); + pendingMsgHolder.setMsg(toCfMsg); + if (toCfMsg.hasTelemetryMsg()) { + log.trace("[{}] Forwarding regular telemetry message for processing {}", id, toCfMsg.getTelemetryMsg()); + forwardToActorSystem(toCfMsg.getTelemetryMsg(), callback); + } else if (toCfMsg.hasLinkedTelemetryMsg()) { + forwardToActorSystem(toCfMsg.getLinkedTelemetryMsg(), callback); + } + } catch (Throwable e) { + log.warn("[{}] Failed to process message: {}", id, msg, e); + callback.onFailure(e); + } + }); + }); + if (!processingTimeoutLatch.await(packProcessingTimeout, TimeUnit.MILLISECONDS)) { + if (!packSubmitFuture.isDone()) { + packSubmitFuture.cancel(true); + log.info("Timeout to process message: {}", pendingMsgHolder.getMsg()); + } + if (log.isDebugEnabled()) { + ctx.getAckMap().forEach((id, msg) -> log.debug("[{}] Timeout to process message: {}", id, msg.getValue())); + } + ctx.getFailedMap().forEach((id, msg) -> log.warn("[{}] Failed to process message: {}", id, msg.getValue())); + } + consumer.commit(); + } + + @Override + protected ServiceType getServiceType() { + return ServiceType.TB_RULE_ENGINE; + } + + @Override + protected long getNotificationPollDuration() { + return pollInterval; + } + + @Override + protected long getNotificationPackProcessingTimeout() { + return packProcessingTimeout; + } + + @Override + protected int getMgmtThreadPoolSize() { + return Math.max(Runtime.getRuntime().availableProcessors(), 4); + } + + @Override + protected TbQueueConsumer> createNotificationsConsumer() { + return queueFactory.createToCalculatedFieldNotificationsMsgConsumer(); + } + + @Override + protected void handleNotification(UUID id, TbProtoQueueMsg msg, TbCallback callback) { + ToCalculatedFieldNotificationMsg toCfNotification = msg.getValue(); + if (toCfNotification.hasLinkedTelemetryMsg()) { + forwardToActorSystem(toCfNotification.getLinkedTelemetryMsg(), callback); + } + } + + @EventListener + public void handleComponentLifecycleEvent(ComponentLifecycleMsg event) { + if (event.getEntityId().getEntityType() == EntityType.TENANT) { + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + Set partitions = stateService.getPartitions(); + if (CollectionUtils.isEmpty(partitions)) { + return; + } + stateService.delete(partitions.stream() + .filter(tpi -> tpi.getTenantId().isPresent() && tpi.getTenantId().get().equals(event.getTenantId())) + .collect(Collectors.toSet())); + } + } + } + + private void forwardToActorSystem(CalculatedFieldTelemetryMsgProto msg, TbCallback callback) { + var tenantId = toTenantId(msg.getTenantIdMSB(), msg.getTenantIdLSB()); + var entityId = EntityIdFactory.getByTypeAndUuid(msg.getEntityType(), new UUID(msg.getEntityIdMSB(), msg.getEntityIdLSB())); + actorContext.tell(new CalculatedFieldTelemetryMsg(tenantId, entityId, msg, callback)); + } + + private void forwardToActorSystem(CalculatedFieldLinkedTelemetryMsgProto linkedMsg, TbCallback callback) { + var msg = linkedMsg.getMsg(); + var tenantId = toTenantId(msg.getTenantIdMSB(), msg.getTenantIdLSB()); + var entityId = EntityIdFactory.getByTypeAndUuid(msg.getEntityType(), new UUID(msg.getEntityIdMSB(), msg.getEntityIdLSB())); + actorContext.tell(new CalculatedFieldLinkedTelemetryMsg(tenantId, entityId, linkedMsg, callback)); + } + + private TenantId toTenantId(long tenantIdMSB, long tenantIdLSB) { + return TenantId.fromUUID(new UUID(tenantIdMSB, tenantIdLSB)); + } + + @Override + protected void stopConsumers() { + super.stopConsumers(); + stateService.stop(); // eventConsumer will be stopped by stateService + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java index af298a9b87..92c48a5fed 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java @@ -38,6 +38,7 @@ import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.id.AssetId; @@ -77,6 +78,8 @@ import org.thingsboard.server.gen.transport.TransportProtos.QueueDeleteMsg; import org.thingsboard.server.gen.transport.TransportProtos.QueueUpdateMsg; import org.thingsboard.server.gen.transport.TransportProtos.ResourceDeleteMsg; import org.thingsboard.server.gen.transport.TransportProtos.ResourceUpdateMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeMsg; @@ -182,6 +185,19 @@ public class DefaultTbClusterService implements TbClusterService { } } + @Override + public void broadcastToCalculatedFields(ToCalculatedFieldNotificationMsg toCfMsg, TbQueueCallback callback) { + UUID msgId = UUID.randomUUID(); + TbQueueProducer> toCfProducer = producerProvider.getCalculatedFieldsNotificationsMsgProducer(); + Set tbReServices = partitionService.getAllServiceIds(ServiceType.TB_RULE_ENGINE); + MultipleTbQueueCallbackWrapper callbackWrapper = new MultipleTbQueueCallbackWrapper(tbReServices.size(), callback); + for (String serviceId : tbReServices) { + TopicPartitionInfo tpi = topicService.getCalculatedFieldNotificationsTopic(serviceId); + toCfProducer.send(tpi, new TbProtoQueueMsg<>(msgId, toCfMsg), callbackWrapper); + toRuleEngineNfs.incrementAndGet(); + } + } + @Override public void pushMsgToVersionControl(TenantId tenantId, ToVersionControlServiceMsg msg, TbQueueCallback callback) { TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_VC_EXECUTOR, TenantId.SYS_TENANT_ID, tenantId); @@ -334,6 +350,19 @@ public class DefaultTbClusterService implements TbClusterService { toTransportNfs.incrementAndGet(); } + @Override + public void pushMsgToCalculatedFields(TenantId tenantId, EntityId entityId, ToCalculatedFieldMsg msg, TbQueueCallback callback) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_RULE_ENGINE, DataConstants.CF_QUEUE_NAME, tenantId, entityId); + pushMsgToCalculatedFields(tpi, UUID.randomUUID(), msg, callback); + } + + @Override + public void pushMsgToCalculatedFields(TopicPartitionInfo tpi, UUID msgId, ToCalculatedFieldMsg msg, TbQueueCallback callback) { + log.trace("PUSHING msg: {} to:{}", msg, tpi); + producerProvider.getCalculatedFieldsMsgProducer().send(tpi, new TbProtoQueueMsg<>(msgId, msg), callback); + toRuleEngineMsgs.incrementAndGet(); // TODO: add separate counter when we will have new ServiceType.CALCULATED_FIELDS + } + @Override public void broadcastEntityStateChangeEvent(TenantId tenantId, EntityId entityId, ComponentLifecycleEvent state) { log.trace("[{}] Processing {} state change event: {}", tenantId, entityId.getEntityType(), state); @@ -394,6 +423,12 @@ public class DefaultTbClusterService implements TbClusterService { broadcastEntityStateChangeEvent(tenantId, deviceId, ComponentLifecycleEvent.DELETED); } + @Override + public void onAssetDeleted(TenantId tenantId, Asset asset, TbQueueCallback callback) { + AssetId assetId = asset.getId(); + broadcastEntityStateChangeEvent(tenantId, assetId, ComponentLifecycleEvent.DELETED); + } + @Override public void onDeviceAssignedToTenant(TenantId oldTenantId, Device device) { onDeviceDeleted(oldTenantId, device, null); @@ -553,7 +588,9 @@ public class DefaultTbClusterService implements TbClusterService { || entityType.equals(EntityType.API_USAGE_STATE) || (entityType.equals(EntityType.DEVICE) && msg.getEvent() == ComponentLifecycleEvent.UPDATED) || entityType.equals(EntityType.ENTITY_VIEW) - || entityType.equals(EntityType.NOTIFICATION_RULE)) { + || entityType.equals(EntityType.NOTIFICATION_RULE) + || entityType.equals(EntityType.CALCULATED_FIELD) + ) { TbQueueProducer> toCoreNfProducer = producerProvider.getTbCoreNotificationsMsgProducer(); Set tbCoreServices = partitionService.getAllServiceIds(ServiceType.TB_CORE); for (String serviceId : tbCoreServices) { @@ -604,21 +641,61 @@ public class DefaultTbClusterService implements TbClusterService { } @Override - public void onDeviceUpdated(Device device, Device old) { + public void onDeviceUpdated(Device entity, Device old) { var created = old == null; - broadcastEntityChangeToTransport(device.getTenantId(), device.getId(), device, null); - if (old != null) { - boolean deviceNameChanged = !device.getName().equals(old.getName()); + broadcastEntityChangeToTransport(entity.getTenantId(), entity.getId(), entity, null); + + var msg = ComponentLifecycleMsg.builder() + .tenantId(entity.getTenantId()) + .entityId(entity.getId()) + .profileId(entity.getDeviceProfileId()) + .name(entity.getName()); + if (created) { + msg.event(ComponentLifecycleEvent.CREATED); + } else { + boolean deviceNameChanged = !entity.getName().equals(old.getName()); if (deviceNameChanged) { - gatewayNotificationsService.onDeviceUpdated(device, old); + gatewayNotificationsService.onDeviceUpdated(entity, old); } - if (deviceNameChanged || !device.getType().equals(old.getType())) { - pushMsgToCore(new DeviceNameOrTypeUpdateMsg(device.getTenantId(), device.getId(), device.getName(), device.getType()), null); + boolean deviceProfileChanged = !entity.getDeviceProfileId().equals(old.getDeviceProfileId()); + if (deviceNameChanged || deviceProfileChanged) { + pushMsgToCore(new DeviceNameOrTypeUpdateMsg(entity.getTenantId(), entity.getId(), entity.getName(), entity.getType()), null); } + msg.event(ComponentLifecycleEvent.UPDATED) + .oldProfileId(old.getDeviceProfileId()) + .oldName(old.getName()); + } + broadcast(msg.build()); + sendDeviceStateServiceEvent(entity.getTenantId(), entity.getId(), created, !created, false); + otaPackageStateService.update(entity, old); + } + + @Override + public void onAssetUpdated(Asset entity, Asset old) { + var created = old == null; + var msg = ComponentLifecycleMsg.builder() + .tenantId(entity.getTenantId()) + .entityId(entity.getId()) + .profileId(entity.getAssetProfileId()) + .name(entity.getName()); + if (created) { + msg.event(ComponentLifecycleEvent.CREATED); + } else { + msg.event(ComponentLifecycleEvent.UPDATED) + .oldProfileId(old.getAssetProfileId()) + .oldName(old.getName()); } - broadcastEntityStateChangeEvent(device.getTenantId(), device.getId(), created ? ComponentLifecycleEvent.CREATED : ComponentLifecycleEvent.UPDATED); - sendDeviceStateServiceEvent(device.getTenantId(), device.getId(), created, !created, false); - otaPackageStateService.update(device, old); + broadcast(msg.build()); + } + + @Override + public void onCalculatedFieldUpdated(CalculatedField calculatedField, CalculatedField oldCalculatedField, TbQueueCallback callback) { + broadcastEntityStateChangeEvent(calculatedField.getTenantId(), calculatedField.getId(), oldCalculatedField == null ? ComponentLifecycleEvent.CREATED : ComponentLifecycleEvent.UPDATED); + } + + @Override + public void onCalculatedFieldDeleted(CalculatedField calculatedField, TbQueueCallback callback) { + broadcastEntityStateChangeEvent(calculatedField.getTenantId(), calculatedField.getId(), ComponentLifecycleEvent.DELETED); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index 586902d542..a3003ba6ff 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -20,9 +20,6 @@ import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; -import lombok.Data; -import lombok.Getter; -import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; @@ -35,6 +32,7 @@ import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.data.JavaSerDesUtil; import org.thingsboard.server.common.data.alarm.AlarmInfo; +import org.thingsboard.server.common.data.edqs.ToCoreEdqsMsg; import org.thingsboard.server.common.data.event.ErrorEvent; import org.thingsboard.server.common.data.event.Event; import org.thingsboard.server.common.data.event.LifecycleEvent; @@ -47,6 +45,7 @@ import org.thingsboard.server.common.data.queue.QueueConfig; import org.thingsboard.server.common.data.rpc.RpcError; import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.TbActorMsg; +import org.thingsboard.server.common.msg.edqs.EdqsService; import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TbCallback; @@ -78,6 +77,7 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToUsageStatsServiceM import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; import org.thingsboard.server.queue.TbQueueConsumer; import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.consumer.MainQueueConsumerManager; import org.thingsboard.server.queue.common.consumer.QueueConsumerManager; import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.discovery.QueueKey; @@ -85,11 +85,11 @@ import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.provider.TbCoreQueueFactory; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.service.cf.CalculatedFieldCache; import org.thingsboard.server.service.notification.NotificationSchedulerService; import org.thingsboard.server.service.ota.OtaPackageStateService; import org.thingsboard.server.service.profile.TbAssetProfileCache; import org.thingsboard.server.service.profile.TbDeviceProfileCache; -import org.thingsboard.server.service.queue.consumer.MainQueueConsumerManager; import org.thingsboard.server.service.queue.processing.AbstractConsumerService; import org.thingsboard.server.service.queue.processing.IdMsgPair; import org.thingsboard.server.service.resource.TbImageService; @@ -147,9 +147,10 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService, CoreQueueConfig> mainConsumer; + private MainQueueConsumerManager, QueueConfig> mainConsumer; private QueueConsumerManager> usageStatsConsumer; private QueueConsumerManager> firmwareStatesConsumer; @@ -175,8 +176,10 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService, CoreQueueConfig>builder() + this.mainConsumer = MainQueueConsumerManager., QueueConfig>builder() .queueKey(new QueueKey(ServiceType.TB_CORE)) - .config(CoreQueueConfig.of(consumerPerPartition, (int) pollInterval)) + .config(QueueConfig.of(consumerPerPartition, pollInterval)) .msgPackProcessor(this::processMsgs) .consumerCreator((config, partitionId) -> queueFactory.createToCoreMsgConsumer()) .consumerExecutor(consumersExecutor) @@ -251,14 +255,14 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService> msgs, TbQueueConsumer> consumer, CoreQueueConfig config) throws Exception { + private void processMsgs(List> msgs, TbQueueConsumer> consumer, QueueConfig config) throws Exception { List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); ConcurrentMap> pendingMap = orderedMsgList.stream().collect( Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); CountDownLatch processingTimeoutLatch = new CountDownLatch(1); TbPackProcessingContext> ctx = new TbPackProcessingContext<>( processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); - PendingMsgHolder pendingMsgHolder = new PendingMsgHolder(); + PendingMsgHolder pendingMsgHolder = new PendingMsgHolder<>(); Future packSubmitFuture = consumersExecutor.submit(() -> { orderedMsgList.forEach((element) -> { UUID id = element.getUuid(); @@ -267,7 +271,7 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService(id, ctx); try { ToCoreMsg toCoreMsg = msg.getValue(); - pendingMsgHolder.setToCoreMsg(toCoreMsg); + pendingMsgHolder.setMsg(toCoreMsg); if (toCoreMsg.hasToSubscriptionMgrMsg()) { log.trace("[{}] Forwarding message to subscription manager service {}", id, toCoreMsg.getToSubscriptionMgrMsg()); forwardToSubMgrService(toCoreMsg.getToSubscriptionMgrMsg(), callback); @@ -289,6 +293,9 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService log.debug("[{}] Timeout to process message: {}", id, msg.getValue())); @@ -329,12 +335,6 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService future = deviceActivityEventsExecutor.submit(() -> stateService.onDeviceInactivityTimeoutUpdate(tenantId, deviceId, deviceInactivityTimeoutUpdateMsg.getInactivityTimeout())); + DonAsynchron.withCallback(future, + __ -> callback.onSuccess(), + t -> { + log.warn("[{}] Failed to process device inactivity timeout update message for device [{}]", tenantId.getId(), deviceId.getId(), t); + callback.onFailure(t); + }); + } + private void forwardToNotificationSchedulerService(TransportProtos.NotificationSchedulerServiceMsg msg, TbCallback callback) { TenantId tenantId = toTenantId(msg.getTenantIdMSB(), msg.getTenantIdLSB()); NotificationRequestId notificationRequestId = new NotificationRequestId(new UUID(msg.getRequestIdMSB(), msg.getRequestIdLSB())); @@ -730,10 +757,4 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService, EdgeQueueConfig> mainConsumer; + private MainQueueConsumerManager, QueueConfig> mainConsumer; public DefaultTbEdgeConsumerService(TbCoreQueueFactory tbCoreQueueFactory, ActorSystemContext actorContext, StatsFactory statsFactory, EdgeContextComponent edgeCtx) { - super(actorContext, null, null, null, null, null, + super(actorContext, null, null, null, null, null, null, null, null); this.edgeCtx = edgeCtx; this.stats = new EdgeConsumerStats(statsFactory); @@ -102,9 +100,9 @@ public class DefaultTbEdgeConsumerService extends AbstractConsumerService, EdgeQueueConfig>builder() + this.mainConsumer = MainQueueConsumerManager., QueueConfig>builder() .queueKey(new QueueKey(ServiceType.TB_CORE).withQueueName(DataConstants.EDGE_QUEUE_NAME)) - .config(EdgeQueueConfig.of(consumerPerPartition, pollInterval)) + .config(QueueConfig.of(consumerPerPartition, pollInterval)) .msgPackProcessor(this::processMsgs) .consumerCreator((config, partitionId) -> queueFactory.createEdgeMsgConsumer()) .consumerExecutor(consumersExecutor) @@ -130,14 +128,14 @@ public class DefaultTbEdgeConsumerService extends AbstractConsumerService> msgs, TbQueueConsumer> consumer, EdgeQueueConfig edgeQueueConfig) throws InterruptedException { + private void processMsgs(List> msgs, TbQueueConsumer> consumer, QueueConfig edgeQueueConfig) throws InterruptedException { List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); ConcurrentMap> pendingMap = orderedMsgList.stream().collect( Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); CountDownLatch processingTimeoutLatch = new CountDownLatch(1); TbPackProcessingContext> ctx = new TbPackProcessingContext<>( processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); - PendingMsgHolder pendingMsgHolder = new PendingMsgHolder(); + PendingMsgHolder pendingMsgHolder = new PendingMsgHolder<>(); Future submitFuture = consumersExecutor.submit(() -> { orderedMsgList.forEach((element) -> { UUID id = element.getUuid(); @@ -145,7 +143,7 @@ public class DefaultTbEdgeConsumerService extends AbstractConsumerService(id, ctx); try { ToEdgeMsg toEdgeMsg = msg.getValue(); - pendingMsgHolder.setToEdgeMsg(toEdgeMsg); + pendingMsgHolder.setMsg(toEdgeMsg); if (toEdgeMsg.hasEdgeNotificationMsg()) { pushNotificationToEdge(toEdgeMsg.getEdgeNotificationMsg(), 0, packProcessingRetries, callback); } @@ -161,20 +159,13 @@ public class DefaultTbEdgeConsumerService extends AbstractConsumerService log.warn("[{}] Failed to process message: {}", id, msg.getValue())); } consumer.commit(); } - private static class PendingMsgHolder { - @Getter - @Setter - private volatile ToEdgeMsg toEdgeMsg; - } - @Override protected ServiceType getServiceType() { return ServiceType.TB_CORE; @@ -294,10 +285,4 @@ public class DefaultTbEdgeConsumerService extends AbstractConsumerService { + event.getNewPartitions().forEach((queueKey, partitions) -> { + if (DataConstants.CF_QUEUE_NAME.equals(queueKey.getQueueName()) || DataConstants.CF_STATES_QUEUE_NAME.equals(queueKey.getQueueName())) { + return; + } if (partitionService.isManagedByCurrentService(queueKey.getTenantId())) { var consumer = getConsumer(queueKey).orElseGet(() -> { Queue config = queueService.findQueueByTenantIdAndName(queueKey.getTenantId(), queueKey.getQueueName()); @@ -227,7 +233,7 @@ public class DefaultTbRuleEngineConsumerService extends AbstractConsumerService< if (event.getEvent() == ComponentLifecycleEvent.DELETED) { List toRemove = consumers.keySet().stream() .filter(queueKey -> queueKey.getTenantId().equals(event.getTenantId())) - .collect(Collectors.toList()); + .toList(); toRemove.forEach(queueKey -> { removeConsumer(queueKey).ifPresent(consumer -> consumer.delete(false)); }); diff --git a/application/src/main/java/org/thingsboard/server/service/queue/PendingMsgHolder.java b/application/src/main/java/org/thingsboard/server/service/queue/PendingMsgHolder.java new file mode 100644 index 0000000000..8f9cb3d092 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/PendingMsgHolder.java @@ -0,0 +1,24 @@ +/** + * 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. + */ +package org.thingsboard.server.service.queue; + +import lombok.Getter; +import lombok.Setter; + +public class PendingMsgHolder { + @Getter @Setter + private volatile T msg; +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/TbCalculatedFieldConsumerService.java new file mode 100644 index 0000000000..8c7a459fab --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbCalculatedFieldConsumerService.java @@ -0,0 +1,23 @@ +/** + * 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. + */ +package org.thingsboard.server.service.queue; + +import org.springframework.context.ApplicationListener; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; + +public interface TbCalculatedFieldConsumerService extends ApplicationListener { + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbCoreConsumerStats.java b/application/src/main/java/org/thingsboard/server/service/queue/TbCoreConsumerStats.java index 728f18bfff..46a42284b4 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/TbCoreConsumerStats.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbCoreConsumerStats.java @@ -40,6 +40,7 @@ public class TbCoreConsumerStats { public static final String DEVICE_ACTIVITIES = "deviceActivity"; public static final String DEVICE_DISCONNECTS = "deviceDisconnect"; public static final String DEVICE_INACTIVITIES = "deviceInactivity"; + public static final String DEVICE_INACTIVITY_TIMEOUT_UPDATES = "deviceInactivityTimeoutUpdate"; public static final String TO_CORE_NF_OTHER = "coreNfOther"; // normally, there is no messages when codebase is fine public static final String TO_CORE_NF_COMPONENT_LIFECYCLE = "coreNfCompLfcl"; @@ -65,6 +66,7 @@ public class TbCoreConsumerStats { private final StatsCounter deviceActivitiesCounter; private final StatsCounter deviceDisconnectsCounter; private final StatsCounter deviceInactivitiesCounter; + private final StatsCounter deviceInactivityTimeoutUpdatesCounter; private final StatsCounter toCoreNfOtherCounter; private final StatsCounter toCoreNfComponentLifecycleCounter; @@ -95,6 +97,7 @@ public class TbCoreConsumerStats { this.deviceActivitiesCounter = register(statsFactory.createStatsCounter(statsKey, DEVICE_ACTIVITIES)); this.deviceDisconnectsCounter = register(statsFactory.createStatsCounter(statsKey, DEVICE_DISCONNECTS)); this.deviceInactivitiesCounter = register(statsFactory.createStatsCounter(statsKey, DEVICE_INACTIVITIES)); + this.deviceInactivityTimeoutUpdatesCounter = register(statsFactory.createStatsCounter(statsKey, DEVICE_INACTIVITY_TIMEOUT_UPDATES)); // Core notification counters this.toCoreNfOtherCounter = register(statsFactory.createStatsCounter(statsKey, TO_CORE_NF_OTHER)); @@ -163,6 +166,11 @@ public class TbCoreConsumerStats { deviceInactivitiesCounter.increment(); } + public void log(TransportProtos.DeviceInactivityTimeoutUpdateProto msg) { + totalCounter.increment(); + deviceInactivityTimeoutUpdatesCounter.increment(); + } + public void log(TransportProtos.SubscriptionMgrMsgProto msg) { totalCounter.increment(); subscriptionMsgCounter.increment(); diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbPackCallback.java b/application/src/main/java/org/thingsboard/server/service/queue/TbPackCallback.java index 4c8edefd36..93112ca0f3 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/TbPackCallback.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbPackCallback.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.queue; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.msg.queue.TbCallback; @@ -23,9 +24,11 @@ import java.util.UUID; @Slf4j public class TbPackCallback implements TbCallback { private final TbPackProcessingContext ctx; + @Getter private final UUID id; public TbPackCallback(UUID id, TbPackProcessingContext ctx) { + log.trace("[{}] CALLBACK CREATED", id); this.id = id; this.ctx = ctx; } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java index 604f76aac1..d12bd896ff 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java @@ -25,6 +25,7 @@ import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; @@ -43,6 +44,7 @@ import org.thingsboard.server.queue.discovery.TbApplicationEventListener; import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.util.AfterStartUp; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.service.cf.CalculatedFieldCache; import org.thingsboard.server.service.profile.TbAssetProfileCache; import org.thingsboard.server.service.profile.TbDeviceProfileCache; import org.thingsboard.server.service.queue.TbPackCallback; @@ -68,6 +70,7 @@ public abstract class AbstractConsumerService partitions; - private boolean drainQueue; - - public static TbQueueConsumerManagerTask delete(boolean drainQueue) { - return new TbQueueConsumerManagerTask(QueueEvent.DELETE, null, null, drainQueue); - } - - public static TbQueueConsumerManagerTask configUpdate(QueueConfig config) { - return new TbQueueConsumerManagerTask(QueueEvent.CONFIG_UPDATE, config, null, false); - } - - public static TbQueueConsumerManagerTask partitionChange(Set partitions) { - return new TbQueueConsumerManagerTask(QueueEvent.PARTITION_CHANGE, null, partitions, false); - } - -} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java b/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java index febbf4fdd4..c7f7f600a7 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java @@ -33,11 +33,14 @@ import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; import org.thingsboard.server.queue.TbQueueConsumer; import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.consumer.MainQueueConsumerManager; +import org.thingsboard.server.queue.common.consumer.TbQueueConsumerManagerTask; +import org.thingsboard.server.queue.common.consumer.TbQueueConsumerManagerTask.DeleteQueueTask; +import org.thingsboard.server.queue.common.consumer.TbQueueConsumerTask; import org.thingsboard.server.queue.discovery.QueueKey; import org.thingsboard.server.service.queue.TbMsgPackCallback; import org.thingsboard.server.service.queue.TbMsgPackProcessingContext; import org.thingsboard.server.service.queue.TbRuleEngineConsumerStats; -import org.thingsboard.server.service.queue.consumer.MainQueueConsumerManager; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingDecision; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingResult; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategy; @@ -69,19 +72,19 @@ public class TbRuleEngineQueueConsumerManager extends MainQueueConsumerManager entityTypes; Resource() { diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java index 76c0e3cf62..a072cf2738 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java @@ -23,7 +23,7 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.service.security.model.SecurityUser; -@Component(value="tenantAdminPermissions") +@Component(value = "tenantAdminPermissions") public class TenantAdminPermissions extends AbstractPermissions { public TenantAdminPermissions() { @@ -55,13 +55,13 @@ public class TenantAdminPermissions extends AbstractPermissions { put(Resource.OAUTH2_CONFIGURATION_TEMPLATE, new PermissionChecker.GenericPermissionChecker(Operation.READ)); put(Resource.MOBILE_APP, tenantEntityPermissionChecker); put(Resource.MOBILE_APP_BUNDLE, tenantEntityPermissionChecker); + put(Resource.CALCULATED_FIELD, tenantEntityPermissionChecker); } public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() { @Override public boolean hasPermission(SecurityUser user, Operation operation, EntityId entityId, HasTenantId entity) { - if (!user.getTenantId().equals(entity.getTenantId())) { return false; } diff --git a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateManager.java b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateManager.java new file mode 100644 index 0000000000..26750e887e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateManager.java @@ -0,0 +1,180 @@ +/** + * 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. + */ +package org.thingsboard.server.service.state; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.rule.engine.api.DeviceStateManager; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.common.SimpleTbQueueCallback; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; + +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Supplier; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DefaultDeviceStateManager implements DeviceStateManager { + + private final TbServiceInfoProvider serviceInfoProvider; + private final PartitionService partitionService; + + private final Optional deviceStateService; + private final TbClusterService clusterService; + + @Override + public void onDeviceConnect(TenantId tenantId, DeviceId deviceId, long connectTime, TbCallback callback) { + forwardToDeviceStateService(tenantId, deviceId, + deviceStateService -> { + log.debug("[{}][{}] Forwarding device connect event to local service. Connect time: [{}].", tenantId.getId(), deviceId.getId(), connectTime); + deviceStateService.onDeviceConnect(tenantId, deviceId, connectTime); + }, + () -> { + log.debug("[{}][{}] Sending device connect message to core. Connect time: [{}].", tenantId.getId(), deviceId.getId(), connectTime); + var deviceConnectMsg = TransportProtos.DeviceConnectProto.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) + .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) + .setLastConnectTime(connectTime) + .build(); + return TransportProtos.ToCoreMsg.newBuilder() + .setDeviceConnectMsg(deviceConnectMsg) + .build(); + }, callback); + } + + @Override + public void onDeviceActivity(TenantId tenantId, DeviceId deviceId, long activityTime, TbCallback callback) { + forwardToDeviceStateService(tenantId, deviceId, + deviceStateService -> { + log.debug("[{}][{}] Forwarding device activity event to local service. Activity time: [{}].", tenantId.getId(), deviceId.getId(), activityTime); + deviceStateService.onDeviceActivity(tenantId, deviceId, activityTime); + }, + () -> { + log.debug("[{}][{}] Sending device activity message to core. Activity time: [{}].", tenantId.getId(), deviceId.getId(), activityTime); + var deviceActivityMsg = TransportProtos.DeviceActivityProto.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) + .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) + .setLastActivityTime(activityTime) + .build(); + return TransportProtos.ToCoreMsg.newBuilder() + .setDeviceActivityMsg(deviceActivityMsg) + .build(); + }, callback); + } + + @Override + public void onDeviceDisconnect(TenantId tenantId, DeviceId deviceId, long disconnectTime, TbCallback callback) { + forwardToDeviceStateService(tenantId, deviceId, + deviceStateService -> { + log.debug("[{}][{}] Forwarding device disconnect event to local service. Disconnect time: [{}].", tenantId.getId(), deviceId.getId(), disconnectTime); + deviceStateService.onDeviceDisconnect(tenantId, deviceId, disconnectTime); + }, + () -> { + log.debug("[{}][{}] Sending device disconnect message to core. Disconnect time: [{}].", tenantId.getId(), deviceId.getId(), disconnectTime); + var deviceDisconnectMsg = TransportProtos.DeviceDisconnectProto.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) + .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) + .setLastDisconnectTime(disconnectTime) + .build(); + return TransportProtos.ToCoreMsg.newBuilder() + .setDeviceDisconnectMsg(deviceDisconnectMsg) + .build(); + }, callback); + } + + @Override + public void onDeviceInactivity(TenantId tenantId, DeviceId deviceId, long inactivityTime, TbCallback callback) { + forwardToDeviceStateService(tenantId, deviceId, + deviceStateService -> { + log.debug("[{}][{}] Forwarding device inactivity event to local service. Inactivity time: [{}].", tenantId.getId(), deviceId.getId(), inactivityTime); + deviceStateService.onDeviceInactivity(tenantId, deviceId, inactivityTime); + }, + () -> { + log.debug("[{}][{}] Sending device inactivity message to core. Inactivity time: [{}].", tenantId.getId(), deviceId.getId(), inactivityTime); + var deviceInactivityMsg = TransportProtos.DeviceInactivityProto.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) + .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) + .setLastInactivityTime(inactivityTime) + .build(); + return TransportProtos.ToCoreMsg.newBuilder() + .setDeviceInactivityMsg(deviceInactivityMsg) + .build(); + }, callback); + } + + @Override + public void onDeviceInactivityTimeoutUpdate(TenantId tenantId, DeviceId deviceId, long inactivityTimeout, TbCallback callback) { + forwardToDeviceStateService(tenantId, deviceId, + deviceStateService -> { + log.debug("[{}][{}] Forwarding device inactivity timeout update to local service. Updated inactivity timeout: [{}].", tenantId.getId(), deviceId.getId(), inactivityTimeout); + deviceStateService.onDeviceInactivityTimeoutUpdate(tenantId, deviceId, inactivityTimeout); + }, + () -> { + log.debug("[{}][{}] Sending device inactivity timeout update message to core. Updated inactivity timeout: [{}].", tenantId.getId(), deviceId.getId(), inactivityTimeout); + var deviceInactivityTimeoutUpdateMsg = TransportProtos.DeviceInactivityTimeoutUpdateProto.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) + .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) + .setInactivityTimeout(inactivityTimeout) + .build(); + return TransportProtos.ToCoreMsg.newBuilder() + .setDeviceInactivityTimeoutUpdateMsg(deviceInactivityTimeoutUpdateMsg) + .build(); + }, callback); + } + + private void forwardToDeviceStateService( + TenantId tenantId, DeviceId deviceId, + Consumer toDeviceStateService, + Supplier toCore, + TbCallback callback + ) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, deviceId); + if (serviceInfoProvider.isService(ServiceType.TB_CORE) && tpi.isMyPartition() && deviceStateService.isPresent()) { + try { + toDeviceStateService.accept(deviceStateService.get()); + } catch (Exception e) { + log.error("[{}][{}] Failed to process device connectivity event.", tenantId.getId(), deviceId.getId(), e); + callback.onFailure(e); + return; + } + callback.onSuccess(); + } else { + TransportProtos.ToCoreMsg toCoreMsg = toCore.get(); + clusterService.pushMsgToCore(tpi, deviceId.getId(), toCoreMsg, new SimpleTbQueueCallback(__ -> callback.onSuccess(), callback::onFailure)); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java index 11819ce5d8..cc476d377d 100644 --- a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java @@ -96,6 +96,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Random; import java.util.Set; import java.util.UUID; @@ -129,11 +130,10 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService PERSISTENT_TELEMETRY_KEYS = Arrays.asList( new EntityKey(EntityKeyType.TIME_SERIES, LAST_ACTIVITY_TIME), new EntityKey(EntityKeyType.TIME_SERIES, INACTIVITY_ALARM_TIME), - new EntityKey(EntityKeyType.TIME_SERIES, INACTIVITY_TIMEOUT), new EntityKey(EntityKeyType.TIME_SERIES, ACTIVITY_STATE), new EntityKey(EntityKeyType.TIME_SERIES, LAST_CONNECT_TIME), new EntityKey(EntityKeyType.TIME_SERIES, LAST_DISCONNECT_TIME), - new EntityKey(EntityKeyType.SERVER_ATTRIBUTE, INACTIVITY_TIMEOUT)); + new EntityKey(EntityKeyType.SERVER_ATTRIBUTE, INACTIVITY_TIMEOUT)); // inactivity timeout is always a server attribute, even when activity data is stored as time series private static final List PERSISTENT_ATTRIBUTE_KEYS = Arrays.asList( new EntityKey(EntityKeyType.SERVER_ATTRIBUTE, LAST_ACTIVITY_TIME), @@ -143,8 +143,14 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService PERSISTENT_ATTRIBUTES = Arrays.asList(ACTIVITY_STATE, LAST_CONNECT_TIME, - LAST_DISCONNECT_TIME, LAST_ACTIVITY_TIME, INACTIVITY_ALARM_TIME, INACTIVITY_TIMEOUT); + public static final Set ACTIVITY_KEYS_WITHOUT_INACTIVITY_TIMEOUT = Set.of( + ACTIVITY_STATE, LAST_CONNECT_TIME, LAST_DISCONNECT_TIME, LAST_ACTIVITY_TIME, INACTIVITY_ALARM_TIME + ); + + public static final Set ACTIVITY_KEYS_WITH_INACTIVITY_TIMEOUT = Set.of( + ACTIVITY_STATE, LAST_CONNECT_TIME, LAST_DISCONNECT_TIME, LAST_ACTIVITY_TIME, INACTIVITY_ALARM_TIME, INACTIVITY_TIMEOUT + ); + private static final List PERSISTENT_ENTITY_FIELDS = Arrays.asList( new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "type"), @@ -251,7 +257,7 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService= deviceState.getLastActivityTime()) { deviceState.setLastInactivityAlarmTime(0L); - save(deviceId, INACTIVITY_ALARM_TIME, 0L); + save(state.getTenantId(), deviceId, INACTIVITY_ALARM_TIME, 0L); } } } @@ -583,7 +589,7 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService fetchDeviceState(Device device) { ListenableFuture future; if (persistToTelemetry) { - ListenableFuture> tsData = tsService.findLatest(TenantId.SYS_TENANT_ID, device.getId(), PERSISTENT_ATTRIBUTES); - future = Futures.transform(tsData, extractDeviceStateData(device), MoreExecutors.directExecutor()); + ListenableFuture> timeseriesActivityDataFuture = tsService.findLatest(TenantId.SYS_TENANT_ID, device.getId(), ACTIVITY_KEYS_WITHOUT_INACTIVITY_TIMEOUT); + ListenableFuture> inactivityTimeoutAttributeFuture = attributesService.find( + TenantId.SYS_TENANT_ID, device.getId(), AttributeScope.SERVER_SCOPE, INACTIVITY_TIMEOUT + ); + + ListenableFuture> fullActivityDataFuture = Futures.whenAllSucceed(timeseriesActivityDataFuture, inactivityTimeoutAttributeFuture).call(() -> { + List activityTimeseries = Futures.getDone(timeseriesActivityDataFuture); + Optional inactivityTimeoutAttribute = Futures.getDone(inactivityTimeoutAttributeFuture); + + if (inactivityTimeoutAttribute.isPresent()) { + List result = new ArrayList<>(activityTimeseries.size() + 1); + result.addAll(activityTimeseries); + result.add(inactivityTimeoutAttribute.get()); + return result; + } else { + return activityTimeseries; + } + }, deviceStateCallbackExecutor); + + future = Futures.transform(fullActivityDataFuture, extractDeviceStateData(device), MoreExecutors.directExecutor()); } else { - ListenableFuture> attrData = attributesService.find(TenantId.SYS_TENANT_ID, device.getId(), AttributeScope.SERVER_SCOPE, PERSISTENT_ATTRIBUTES); - future = Futures.transform(attrData, extractDeviceStateData(device), MoreExecutors.directExecutor()); + ListenableFuture> attributesActivityDataFuture = attributesService.find( + TenantId.SYS_TENANT_ID, device.getId(), AttributeScope.SERVER_SCOPE, ACTIVITY_KEYS_WITH_INACTIVITY_TIMEOUT + ); + future = Futures.transform(attributesActivityDataFuture, extractDeviceStateData(device), MoreExecutors.directExecutor()); } - return transformInactivityTimeout(future); + return future; } - private ListenableFuture transformInactivityTimeout(ListenableFuture future) { - return Futures.transformAsync(future, deviceStateData -> { - if (!persistToTelemetry || deviceStateData.getState().getInactivityTimeout() != defaultInactivityTimeoutMs) { - return future; //fail fast - } - var attributesFuture = attributesService.find(TenantId.SYS_TENANT_ID, deviceStateData.getDeviceId(), AttributeScope.SERVER_SCOPE, INACTIVITY_TIMEOUT); - return Futures.transform(attributesFuture, attributes -> { - attributes.flatMap(KvEntry::getLongValue).ifPresent((inactivityTimeout) -> { - if (inactivityTimeout > 0) { - deviceStateData.getState().setInactivityTimeout(inactivityTimeout); - } - }); - return deviceStateData; - }, MoreExecutors.directExecutor()); - }, deviceStateCallbackExecutor); - } - - private Function, DeviceStateData> extractDeviceStateData(Device device) { + private Function, DeviceStateData> extractDeviceStateData(Device device) { return new Function<>() { @Nonnull @Override - public DeviceStateData apply(@Nullable List data) { + public DeviceStateData apply(@Nullable List data) { try { long lastActivityTime = getEntryValue(data, LAST_ACTIVITY_TIME, 0L); long inactivityAlarmTime = getEntryValue(data, INACTIVITY_ALARM_TIME, 0L); @@ -690,7 +698,7 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService 0 ? inactivityTimeout : defaultInactivityTimeoutMs) .build(); TbMsgMetaData md = new TbMsgMetaData(); md.putValue("deviceName", device.getName()); @@ -761,12 +769,7 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService deviceStateService; - private final TbClusterService clusterService; - - public DefaultRuleEngineDeviceStateManager( - TbServiceInfoProvider serviceInfoProvider, PartitionService partitionService, - Optional deviceStateServiceOptional, TbClusterService clusterService - ) { - this.serviceInfoProvider = serviceInfoProvider; - this.partitionService = partitionService; - this.deviceStateService = deviceStateServiceOptional; - this.clusterService = clusterService; - } - - @Getter - private abstract static class ConnectivityEventInfo { - - private final TenantId tenantId; - private final DeviceId deviceId; - private final long eventTime; - - private ConnectivityEventInfo(TenantId tenantId, DeviceId deviceId, long eventTime) { - this.tenantId = tenantId; - this.deviceId = deviceId; - this.eventTime = eventTime; - } - - abstract void forwardToLocalService(); - - abstract TransportProtos.ToCoreMsg toQueueMsg(); - - } - - @Override - public void onDeviceConnect(TenantId tenantId, DeviceId deviceId, long connectTime, TbCallback callback) { - routeEvent(new ConnectivityEventInfo(tenantId, deviceId, connectTime) { - @Override - void forwardToLocalService() { - deviceStateService.ifPresent(service -> service.onDeviceConnect(tenantId, deviceId, connectTime)); - } - - @Override - TransportProtos.ToCoreMsg toQueueMsg() { - var deviceConnectMsg = TransportProtos.DeviceConnectProto.newBuilder() - .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) - .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) - .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) - .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) - .setLastConnectTime(connectTime) - .build(); - return TransportProtos.ToCoreMsg.newBuilder() - .setDeviceConnectMsg(deviceConnectMsg) - .build(); - } - }, callback); - } - - @Override - public void onDeviceActivity(TenantId tenantId, DeviceId deviceId, long activityTime, TbCallback callback) { - routeEvent(new ConnectivityEventInfo(tenantId, deviceId, activityTime) { - @Override - void forwardToLocalService() { - deviceStateService.ifPresent(service -> service.onDeviceActivity(tenantId, deviceId, activityTime)); - } - - @Override - TransportProtos.ToCoreMsg toQueueMsg() { - var deviceActivityMsg = TransportProtos.DeviceActivityProto.newBuilder() - .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) - .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) - .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) - .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) - .setLastActivityTime(activityTime) - .build(); - return TransportProtos.ToCoreMsg.newBuilder() - .setDeviceActivityMsg(deviceActivityMsg) - .build(); - } - }, callback); - } - - @Override - public void onDeviceDisconnect(TenantId tenantId, DeviceId deviceId, long disconnectTime, TbCallback callback) { - routeEvent(new ConnectivityEventInfo(tenantId, deviceId, disconnectTime) { - @Override - void forwardToLocalService() { - deviceStateService.ifPresent(service -> service.onDeviceDisconnect(tenantId, deviceId, disconnectTime)); - } - - @Override - TransportProtos.ToCoreMsg toQueueMsg() { - var deviceDisconnectMsg = TransportProtos.DeviceDisconnectProto.newBuilder() - .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) - .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) - .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) - .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) - .setLastDisconnectTime(disconnectTime) - .build(); - return TransportProtos.ToCoreMsg.newBuilder() - .setDeviceDisconnectMsg(deviceDisconnectMsg) - .build(); - } - }, callback); - } - - @Override - public void onDeviceInactivity(TenantId tenantId, DeviceId deviceId, long inactivityTime, TbCallback callback) { - routeEvent(new ConnectivityEventInfo(tenantId, deviceId, inactivityTime) { - @Override - void forwardToLocalService() { - deviceStateService.ifPresent(service -> service.onDeviceInactivity(tenantId, deviceId, inactivityTime)); - } - - @Override - TransportProtos.ToCoreMsg toQueueMsg() { - var deviceInactivityMsg = TransportProtos.DeviceInactivityProto.newBuilder() - .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) - .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) - .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) - .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) - .setLastInactivityTime(inactivityTime) - .build(); - return TransportProtos.ToCoreMsg.newBuilder() - .setDeviceInactivityMsg(deviceInactivityMsg) - .build(); - } - }, callback); - } - - private void routeEvent(ConnectivityEventInfo eventInfo, TbCallback callback) { - var tenantId = eventInfo.getTenantId(); - var deviceId = eventInfo.getDeviceId(); - long eventTime = eventInfo.getEventTime(); - - TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, deviceId); - if (serviceInfoProvider.isService(ServiceType.TB_CORE) && tpi.isMyPartition() && deviceStateService.isPresent()) { - log.debug("[{}][{}] Forwarding device connectivity event to local service. Event time: [{}].", tenantId.getId(), deviceId.getId(), eventTime); - try { - eventInfo.forwardToLocalService(); - } catch (Exception e) { - log.error("[{}][{}] Failed to process device connectivity event. Event time: [{}].", tenantId.getId(), deviceId.getId(), eventTime, e); - callback.onFailure(e); - return; - } - callback.onSuccess(); - } else { - TransportProtos.ToCoreMsg msg = eventInfo.toQueueMsg(); - log.debug("[{}][{}] Sending device connectivity message to core. Event time: [{}].", tenantId.getId(), deviceId.getId(), eventTime); - clusterService.pushMsgToCore(tpi, UUID.randomUUID(), msg, new SimpleTbQueueCallback(__ -> callback.onSuccess(), callback::onFailure)); - } - } - -} diff --git a/application/src/main/java/org/thingsboard/server/service/stats/DefaultJsInvokeStats.java b/application/src/main/java/org/thingsboard/server/service/stats/DefaultJsInvokeStats.java deleted file mode 100644 index e160e0e5b5..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/stats/DefaultJsInvokeStats.java +++ /dev/null @@ -1,83 +0,0 @@ -/** - * 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. - */ -package org.thingsboard.server.service.stats; - -import jakarta.annotation.PostConstruct; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; -import org.thingsboard.server.actors.JsInvokeStats; -import org.thingsboard.server.common.stats.StatsCounter; -import org.thingsboard.server.common.stats.StatsFactory; -import org.thingsboard.server.common.stats.StatsType; - -@Service -public class DefaultJsInvokeStats implements JsInvokeStats { - private static final String REQUESTS = "requests"; - private static final String RESPONSES = "responses"; - private static final String FAILURES = "failures"; - - private StatsCounter requestsCounter; - private StatsCounter responsesCounter; - private StatsCounter failuresCounter; - - @Autowired - private StatsFactory statsFactory; - - @PostConstruct - public void init() { - String key = StatsType.JS_INVOKE.getName(); - this.requestsCounter = statsFactory.createStatsCounter(key, REQUESTS); - this.responsesCounter = statsFactory.createStatsCounter(key, RESPONSES); - this.failuresCounter = statsFactory.createStatsCounter(key, FAILURES); - } - - @Override - public void incrementRequests(int amount) { - requestsCounter.add(amount); - } - - @Override - public void incrementResponses(int amount) { - responsesCounter.add(amount); - } - - @Override - public void incrementFailures(int amount) { - failuresCounter.add(amount); - } - - @Override - public int getRequests() { - return requestsCounter.get(); - } - - @Override - public int getResponses() { - return responsesCounter.get(); - } - - @Override - public int getFailures() { - return failuresCounter.get(); - } - - @Override - public void reset() { - requestsCounter.clear(); - responsesCounter.clear(); - failuresCounter.clear(); - } -} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java index 23adacfc2e..1fafb562c1 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java @@ -21,7 +21,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.thingsboard.server.cluster.TbClusterService; -import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.alarm.AlarmInfo; import org.thingsboard.server.common.data.id.DeviceId; @@ -49,8 +48,6 @@ import org.thingsboard.server.queue.discovery.event.OtherServiceShutdownEvent; import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.state.DefaultDeviceStateService; -import org.thingsboard.server.service.state.DeviceStateService; import org.thingsboard.server.service.ws.notification.sub.NotificationUpdate; import org.thingsboard.server.service.ws.notification.sub.NotificationsSubscriptionUpdate; @@ -76,7 +73,6 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene private final TbServiceInfoProvider serviceInfoProvider; private final TbQueueProducerProvider producerProvider; private final TbLocalSubscriptionService localSubscriptionService; - private final DeviceStateService deviceStateService; private final TbClusterService clusterService; private final SubscriptionSchedulerComponent scheduler; @@ -161,9 +157,6 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene @Override public void onTimeSeriesUpdate(TenantId tenantId, EntityId entityId, List ts, TbCallback callback) { onTimeSeriesUpdate(entityId, ts); - if (entityId.getEntityType() == EntityType.DEVICE) { - updateDeviceInactivityTimeout(tenantId, entityId, ts); - } callback.onSuccess(); } @@ -171,13 +164,10 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene public void onTimeSeriesDelete(TenantId tenantId, EntityId entityId, List keys, TbCallback callback) { onTimeSeriesUpdate(entityId, keys.stream().map(key -> new BasicTsKvEntry(0, new StringDataEntry(key, ""))).collect(Collectors.toList())); - if (entityId.getEntityType() == EntityType.DEVICE) { - deleteDeviceInactivityTimeout(tenantId, entityId, keys); - } callback.onSuccess(); } - public void onTimeSeriesUpdate(EntityId entityId, List update) { + private void onTimeSeriesUpdate(EntityId entityId, List update) { getEntityUpdatesInfo(entityId).timeSeriesUpdateTs = System.currentTimeMillis(); TbEntityRemoteSubsInfo subInfo = entitySubscriptions.get(entityId); if (subInfo != null) { @@ -207,42 +197,27 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene @Override public void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List attributes, TbCallback callback) { - onAttributesUpdate(tenantId, entityId, scope, attributes, true, callback); - } - - @Override - public void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List attributes, boolean notifyDevice, TbCallback callback) { getEntityUpdatesInfo(entityId).attributesUpdateTs = System.currentTimeMillis(); processAttributesUpdate(entityId, scope, attributes); - if (entityId.getEntityType() == EntityType.DEVICE) { - if (TbAttributeSubscriptionScope.SERVER_SCOPE.name().equalsIgnoreCase(scope)) { - updateDeviceInactivityTimeout(tenantId, entityId, attributes); - } else if (TbAttributeSubscriptionScope.SHARED_SCOPE.name().equalsIgnoreCase(scope) && notifyDevice) { - clusterService.pushMsgToCore(DeviceAttributesEventNotificationMsg.onUpdate(tenantId, - new DeviceId(entityId.getId()), DataConstants.SHARED_SCOPE, new ArrayList<>(attributes)) - , null); - } - } callback.onSuccess(); } + @Override + public void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List keys, TbCallback callback) { + onAttributesDelete(tenantId, entityId, scope, keys, false, callback); + } + @Override public void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List keys, boolean notifyDevice, TbCallback callback) { processAttributesUpdate(entityId, scope, keys.stream().map(key -> new BaseAttributeKvEntry(0, new StringDataEntry(key, ""))).collect(Collectors.toList())); - if (entityId.getEntityType() == EntityType.DEVICE) { - if (TbAttributeSubscriptionScope.SERVER_SCOPE.name().equalsIgnoreCase(scope) - || TbAttributeSubscriptionScope.ANY_SCOPE.name().equalsIgnoreCase(scope)) { - deleteDeviceInactivityTimeout(tenantId, entityId, keys); - } else if (TbAttributeSubscriptionScope.SHARED_SCOPE.name().equalsIgnoreCase(scope) && notifyDevice) { - clusterService.pushMsgToCore(DeviceAttributesEventNotificationMsg.onDelete(tenantId, - new DeviceId(entityId.getId()), scope, keys), null); - } + if (entityId.getEntityType() == EntityType.DEVICE && TbAttributeSubscriptionScope.SHARED_SCOPE.name().equalsIgnoreCase(scope) && notifyDevice) { + clusterService.pushMsgToCore(DeviceAttributesEventNotificationMsg.onDelete(tenantId, new DeviceId(entityId.getId()), scope, keys), null); } callback.onSuccess(); } - public void processAttributesUpdate(EntityId entityId, String scope, List update) { + private void processAttributesUpdate(EntityId entityId, String scope, List update) { TbEntityRemoteSubsInfo subInfo = entitySubscriptions.get(entityId); if (subInfo != null) { log.trace("[{}] Handling attributes update: {}", entityId, update); @@ -270,22 +245,6 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene } } - private void updateDeviceInactivityTimeout(TenantId tenantId, EntityId entityId, List kvEntries) { - for (KvEntry kvEntry : kvEntries) { - if (kvEntry.getKey().equals(DefaultDeviceStateService.INACTIVITY_TIMEOUT)) { - deviceStateService.onDeviceInactivityTimeoutUpdate(tenantId, new DeviceId(entityId.getId()), getLongValue(kvEntry)); - } - } - } - - private void deleteDeviceInactivityTimeout(TenantId tenantId, EntityId entityId, List keys) { - for (String key : keys) { - if (key.equals(DefaultDeviceStateService.INACTIVITY_TIMEOUT)) { - deviceStateService.onDeviceInactivityTimeoutUpdate(tenantId, new DeviceId(entityId.getId()), 0); - } - } - } - @Override public void onAlarmUpdate(TenantId tenantId, EntityId entityId, AlarmInfo alarm, TbCallback callback) { onAlarmSubUpdate(tenantId, entityId, alarm, false, callback); @@ -355,29 +314,6 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene } } - private static long getLongValue(KvEntry kve) { - switch (kve.getDataType()) { - case LONG: - return kve.getLongValue().orElse(0L); - case DOUBLE: - return kve.getDoubleValue().orElse(0.0).longValue(); - case STRING: - try { - return Long.parseLong(kve.getStrValue().orElse("0")); - } catch (NumberFormatException e) { - return 0L; - } - case JSON: - try { - return Long.parseLong(kve.getJsonValue().orElse("0")); - } catch (NumberFormatException e) { - return 0L; - } - default: - return 0L; - } - } - private static List getSubList(List ts, Set keys) { List update = null; for (T entry : ts) { diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java index 3fc314b632..9e9ca42e83 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java @@ -33,7 +33,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.socket.CloseStatus; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.common.util.ThingsBoardThreadFactory; -import org.thingsboard.server.common.data.alarm.AlarmInfo; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult; @@ -41,7 +41,6 @@ import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.ComparisonTsValue; -import org.thingsboard.server.common.data.query.OriginatorAlarmFilter; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityKey; @@ -55,17 +54,16 @@ import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.executors.DbCallbackExecutorService; -import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.ws.WebSocketService; import org.thingsboard.server.service.ws.WebSocketSessionRef; import org.thingsboard.server.service.ws.telemetry.cmd.v2.AggHistoryCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.AggKey; import org.thingsboard.server.service.ws.telemetry.cmd.v2.AggTimeSeriesCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.AlarmCountCmd; +import org.thingsboard.server.service.ws.telemetry.cmd.v2.AlarmCountUpdate; import org.thingsboard.server.service.ws.telemetry.cmd.v2.AlarmDataCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.AlarmDataUpdate; import org.thingsboard.server.service.ws.telemetry.cmd.v2.AlarmStatusCmd; -import org.thingsboard.server.service.ws.telemetry.cmd.v2.CmdUpdate; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityCountCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityDataCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityDataUpdate; @@ -74,7 +72,6 @@ import org.thingsboard.server.service.ws.telemetry.cmd.v2.GetTsCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.LatestValueCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.TimeSeriesCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.UnsubscribeCmd; -import org.thingsboard.server.service.ws.telemetry.sub.AlarmSubscriptionUpdate; import java.util.ArrayList; import java.util.Arrays; @@ -83,7 +80,6 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutionException; @@ -430,13 +426,25 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc long start = System.currentTimeMillis(); ctx.fetchData(); long end = System.currentTimeMillis(); - stats.getAlarmQueryInvocationCnt().incrementAndGet(); - stats.getAlarmQueryTimeSpent().addAndGet(end - start); - TbAlarmCountSubCtx finalCtx = ctx; - ScheduledFuture task = scheduler.scheduleWithFixedDelay( - () -> refreshDynamicQuery(finalCtx), - dynamicPageLinkRefreshInterval, dynamicPageLinkRefreshInterval, TimeUnit.SECONDS); - finalCtx.setRefreshTask(task); + stats.getRegularQueryInvocationCnt().incrementAndGet(); + stats.getRegularQueryTimeSpent().addAndGet(end - start); + Set entitiesIds = ctx.getEntitiesIds(); + ctx.cancelTasks(); + ctx.clearAlarmSubscriptions(); + if (entitiesIds != null && entitiesIds.isEmpty()) { + AlarmCountUpdate update = new AlarmCountUpdate(cmd.getCmdId(), 0); + ctx.sendWsMsg(update); + } else { + ctx.doFetchAlarmCount(); + if (entitiesIds != null) { + ctx.createAlarmSubscriptions(); + } + TbAlarmCountSubCtx finalCtx = ctx; + ScheduledFuture task = scheduler.scheduleWithFixedDelay( + () -> refreshDynamicQuery(finalCtx), + dynamicPageLinkRefreshInterval, dynamicPageLinkRefreshInterval, TimeUnit.SECONDS); + finalCtx.setRefreshTask(task); + } } else { log.debug("[{}][{}] Received duplicate command: {}", session.getSessionId(), cmd.getCmdId(), cmd); } @@ -555,7 +563,7 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc private TbAlarmCountSubCtx createSubCtx(WebSocketSessionRef sessionRef, AlarmCountCmd cmd) { Map sessionSubs = subscriptionsBySessionId.computeIfAbsent(sessionRef.getSessionId(), k -> new ConcurrentHashMap<>()); TbAlarmCountSubCtx ctx = new TbAlarmCountSubCtx(serviceId, wsService, entityService, localSubscriptionService, - attributesService, stats, alarmService, sessionRef, cmd.getCmdId()); + attributesService, stats, alarmService, sessionRef, cmd.getCmdId(), maxEntitiesPerAlarmSubscription, maxAlarmQueriesPerRefreshInterval); if (cmd.getQuery() != null) { ctx.setAndResolveQuery(cmd.getQuery()); } diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java b/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java index d199f18b75..57d4fda5f8 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java @@ -39,8 +39,15 @@ public interface SubscriptionManagerService extends ApplicationListener attributes, TbCallback callback); - void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List attributes, boolean notifyDevice, TbCallback callback); - + void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List keys, TbCallback empty); + + /** + * This method is retained solely for backwards compatibility, specifically to handle + * legacy proto messages that include the notifyDevice field. + * + * @deprecated as of 4.0, this method will be removed in future releases. + */ + @Deprecated(forRemoval = true, since = "4.0") void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List keys, boolean notifyDevice, TbCallback empty); void onTimeSeriesDelete(TenantId tenantId, EntityId entityId, List keys, TbCallback callback); diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java index ae3c2e0ccd..6689c11c42 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java @@ -19,49 +19,152 @@ import lombok.Getter; import lombok.Setter; import lombok.ToString; import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.AlarmCountQuery; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.entity.EntityService; +import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.service.ws.WebSocketService; import org.thingsboard.server.service.ws.WebSocketSessionRef; import org.thingsboard.server.service.ws.telemetry.cmd.v2.AlarmCountUpdate; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + @Slf4j @ToString(callSuper = true) public class TbAlarmCountSubCtx extends TbAbstractEntityQuerySubCtx { private final AlarmService alarmService; + protected final Map subToEntityIdMap; + + @Getter + private LinkedHashSet entitiesIds; + + private final int maxEntitiesPerAlarmSubscription; + + private final int maxAlarmQueriesPerRefreshInterval; + @Getter @Setter private volatile int result; + @Getter + @Setter + private boolean tooManyEntities; + + private int alarmCountInvocationAttempts; + public TbAlarmCountSubCtx(String serviceId, WebSocketService wsService, EntityService entityService, TbLocalSubscriptionService localSubscriptionService, AttributesService attributesService, SubscriptionServiceStatistics stats, AlarmService alarmService, - WebSocketSessionRef sessionRef, int cmdId) { + WebSocketSessionRef sessionRef, int cmdId, int maxEntitiesPerAlarmSubscription, int maxAlarmQueriesPerRefreshInterval) { super(serviceId, wsService, entityService, localSubscriptionService, attributesService, stats, sessionRef, cmdId); this.alarmService = alarmService; + this.subToEntityIdMap = new ConcurrentHashMap<>(); + this.maxEntitiesPerAlarmSubscription = maxEntitiesPerAlarmSubscription; + this.maxAlarmQueriesPerRefreshInterval = maxAlarmQueriesPerRefreshInterval; + this.entitiesIds = null; + } + + @Override + public void clearSubscriptions() { + clearAlarmSubscriptions(); } @Override public void fetchData() { - result = (int) alarmService.countAlarmsByQuery(getTenantId(), getCustomerId(), query); - sendWsMsg(new AlarmCountUpdate(cmdId, result)); + resetInvocationCounter(); + if (query.getEntityFilter() != null) { + entitiesIds = new LinkedHashSet<>(); + log.trace("[{}] Fetching data: {}", cmdId, alarmCountInvocationAttempts); + PageData data = entityService.findEntityDataByQuery(getTenantId(), getCustomerId(), buildEntityDataQuery()); + entitiesIds.clear(); + tooManyEntities = data.hasNext(); + for (EntityData entityData : data.getData()) { + entitiesIds.add(entityData.getEntityId()); + } + } } @Override protected void update() { - int newCount = (int) alarmService.countAlarmsByQuery(getTenantId(), getCustomerId(), query); - if (newCount != result) { - result = newCount; - sendWsMsg(new AlarmCountUpdate(cmdId, result)); - } + resetInvocationCounter(); + fetchAlarmCount(); } @Override public boolean isDynamic() { return true; } + + public void fetchAlarmCount() { + alarmCountInvocationAttempts++; + log.trace("[{}] Fetching alarms: {}", cmdId, alarmCountInvocationAttempts); + if (alarmCountInvocationAttempts <= maxAlarmQueriesPerRefreshInterval) { + int newCount = (int) alarmService.countAlarmsByQuery(getTenantId(), getCustomerId(), query, entitiesIds); + if (newCount != result) { + result = newCount; + sendWsMsg(new AlarmCountUpdate(cmdId, result)); + } + } else { + log.trace("[{}] Ignore alarm count fetch due to rate limit: [{}] of maximum [{}]", cmdId, alarmCountInvocationAttempts, maxAlarmQueriesPerRefreshInterval); + } + } + + public void doFetchAlarmCount() { + result = (int) alarmService.countAlarmsByQuery(getTenantId(), getCustomerId(), query, entitiesIds); + sendWsMsg(new AlarmCountUpdate(cmdId, result)); + } + + private EntityDataQuery buildEntityDataQuery() { + EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, + new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, ModelConstants.CREATED_TIME_PROPERTY))); + return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters()); + } + + private void resetInvocationCounter() { + alarmCountInvocationAttempts = 0; + } + + public void createAlarmSubscriptions() { + for (EntityId entityId : entitiesIds) { + createAlarmSubscriptionForEntity(entityId); + } + } + + private void createAlarmSubscriptionForEntity(EntityId entityId) { + int subIdx = sessionRef.getSessionSubIdSeq().incrementAndGet(); + subToEntityIdMap.put(subIdx, entityId); + log.trace("[{}][{}][{}] Creating alarms subscription for [{}] ", serviceId, cmdId, subIdx, entityId); + TbAlarmsSubscription subscription = TbAlarmsSubscription.builder() + .serviceId(serviceId) + .sessionId(sessionRef.getSessionId()) + .subscriptionId(subIdx) + .tenantId(sessionRef.getSecurityCtx().getTenantId()) + .entityId(entityId) + .updateProcessor((sub, update) -> fetchAlarmCount()) + .build(); + localSubscriptionService.addSubscription(subscription, sessionRef); + } + + public void clearAlarmSubscriptions() { + if (subToEntityIdMap != null) { + for (Integer subId : subToEntityIdMap.keySet()) { + localSubscriptionService.cancelSubscription(getTenantId(), getSessionId(), subId); + } + subToEntityIdMap.clear(); + } + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java index f6ce2a6a6b..f6b4067543 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java @@ -39,6 +39,7 @@ import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.sql.query.EntityKeyMapping; import org.thingsboard.server.service.ws.WebSocketService; import org.thingsboard.server.service.ws.WebSocketSessionRef; import org.thingsboard.server.service.ws.telemetry.cmd.v2.AlarmDataUpdate; @@ -359,7 +360,7 @@ public class TbAlarmDataSubCtx extends TbAbstractDataSubCtx { EntityDataSortOrder sortOrder = query.getPageLink().getSortOrder(); EntityDataSortOrder entitiesSortOrder; if (sortOrder == null || sortOrder.getKey().getType().equals(EntityKeyType.ALARM_FIELD)) { - entitiesSortOrder = new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, ModelConstants.CREATED_TIME_PROPERTY)); + entitiesSortOrder = new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, EntityKeyMapping.CREATED_TIME)); } else { entitiesSortOrder = sortOrder; } diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionUtils.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionUtils.java index 68c0f52f71..1d5e85cc22 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionUtils.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionUtils.java @@ -209,7 +209,7 @@ public class TbSubscriptionUtils { return ToCoreMsg.newBuilder().setToSubscriptionMgrMsg(msgBuilder.build()).build(); } - public static ToCoreMsg toAttributesDeleteProto(TenantId tenantId, EntityId entityId, String scope, List keys, boolean notifyDevice) { + public static ToCoreMsg toAttributesDeleteProto(TenantId tenantId, EntityId entityId, String scope, List keys) { TbAttributeDeleteProto.Builder builder = TbAttributeDeleteProto.newBuilder(); builder.setEntityType(entityId.getEntityType().name()); builder.setEntityIdMSB(entityId.getId().getMostSignificantBits()); @@ -218,7 +218,6 @@ public class TbSubscriptionUtils { builder.setTenantIdLSB(tenantId.getId().getLeastSignificantBits()); builder.setScope(scope); builder.addAllKeys(keys); - builder.setNotifyDevice(notifyDevice); SubscriptionMgrMsgProto.Builder msgBuilder = SubscriptionMgrMsgProto.newBuilder(); msgBuilder.setAttrDelete(builder); diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java index 178dde33e2..06fe7f4036 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java @@ -31,6 +31,7 @@ import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.sync.ie.EntityExportData; import org.thingsboard.server.common.data.sync.ie.EntityImportResult; import org.thingsboard.server.common.data.util.ThrowingRunnable; +import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -61,6 +62,7 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS private final Map> importServices = new HashMap<>(); private final RelationService relationService; + private final CalculatedFieldService calculatedFieldService; private final RateLimitService rateLimitService; private final TbLogEntityActionService logEntityActionService; @@ -72,7 +74,6 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE ); - @Override public , I extends EntityId> EntityExportData exportEntity(EntitiesExportCtx ctx, I entityId) throws ThingsboardException { if (!rateLimitService.checkRateLimit(LimitedApi.ENTITY_EXPORT, ctx.getTenantId())) { @@ -129,13 +130,11 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS } } - @Override public Comparator getEntityTypeComparatorForImport() { return Comparator.comparing(SUPPORTED_ENTITY_TYPES::indexOf); } - @SuppressWarnings("unchecked") private , D extends EntityExportData> EntityExportService getExportService(EntityType entityType) { EntityExportService exportService = exportServices.get(entityType); diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/EntitiesExportImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/EntitiesExportImportService.java index 2b7e7d3593..b77447df8d 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/EntitiesExportImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/EntitiesExportImportService.java @@ -32,7 +32,6 @@ public interface EntitiesExportImportService { , I extends EntityId> EntityImportResult importEntity(EntitiesImportCtx ctx, EntityExportData exportData) throws ThingsboardException; - void saveReferencesAndRelations(EntitiesImportCtx ctx) throws ThingsboardException; Comparator getEntityTypeComparatorForImport(); diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java index b0bea68290..5c2fcc7dc6 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ExportableEntity; import org.thingsboard.server.common.data.HasVersion; +import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; @@ -31,6 +32,7 @@ import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.sync.ie.AttributeExportData; import org.thingsboard.server.common.data.sync.ie.EntityExportData; import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.relation.RelationDao; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.sync.ie.exporting.EntityExportService; @@ -59,6 +61,8 @@ public class DefaultEntityExportService ctx, I entityId) throws ThingsboardException { @@ -98,6 +102,10 @@ public class DefaultEntityExportService> attributes = exportAttributes(ctx, entity); exportData.setAttributes(attributes); } + if (ctx.getSettings().isExportCalculatedFields()) { + List calculatedFields = exportCalculatedFields(ctx, entity.getId()); + exportData.setCalculatedFields(calculatedFields); + } } private List exportRelations(EntitiesExportCtx ctx, E entity) throws ThingsboardException { @@ -141,6 +149,19 @@ public class DefaultEntityExportService exportCalculatedFields(EntitiesExportCtx ctx, EntityId entityId) { + List calculatedFields = calculatedFieldService.findCalculatedFieldsByEntityId(ctx.getTenantId(), entityId); + calculatedFields.forEach(calculatedField -> { + calculatedField.setEntityId(getExternalIdOrElseInternal(ctx, entityId)); + calculatedField.getConfiguration().getArguments().values().forEach(argument -> { + if (argument.getRefEntityId() != null) { + argument.setRefEntityId(getExternalIdOrElseInternal(ctx, argument.getRefEntityId())); + } + }); + }); + return calculatedFields; + } + protected ID getExternalIdOrElseInternal(EntitiesExportCtx ctx, ID internalId) { if (internalId == null || internalId.isNullUid()) return internalId; var result = ctx.getExternalId(internalId); diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AssetImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AssetImportService.java index 00ce23b2e8..7cd4c3aca1 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AssetImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AssetImportService.java @@ -47,7 +47,11 @@ public class AssetImportService extends BaseEntityImportService exportData, IdProvider idProvider) { - return assetService.saveAsset(asset); + Asset savedAsset = assetService.saveAsset(asset); + if (ctx.isFinalImportAttempt() || ctx.getCurrentImportResult().isUpdatedAllExternalIds()) { + importCalculatedFields(ctx, savedAsset, exportData, idProvider); + } + return savedAsset; } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AssetProfileImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AssetProfileImportService.java index 97c54957bf..32a0090a4a 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AssetProfileImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AssetProfileImportService.java @@ -50,7 +50,11 @@ public class AssetProfileImportService extends BaseEntityImportService exportData, IdProvider idProvider) { - return assetProfileService.saveAssetProfile(assetProfile); + AssetProfile saved = assetProfileService.saveAssetProfile(assetProfile); + if (ctx.isFinalImportAttempt() || ctx.getCurrentImportResult().isUpdatedAllExternalIds()) { + importCalculatedFields(ctx, saved, exportData, idProvider); + } + return saved; } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java index 7044b235a0..bfa95af83c 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java @@ -32,6 +32,7 @@ import org.thingsboard.server.common.data.HasDefaultOption; import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; @@ -50,6 +51,7 @@ import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.sync.ie.AttributeExportData; import org.thingsboard.server.common.data.sync.ie.EntityExportData; import org.thingsboard.server.common.data.sync.ie.EntityImportResult; +import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.relation.RelationDao; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.service.action.EntityActionService; @@ -78,6 +80,8 @@ public abstract class BaseEntityImportService existing = calculatedFieldService.findCalculatedFieldsByEntityId(ctx.getTenantId(), savedEntity.getId()); + List fieldsToSave = exportData.getCalculatedFields().stream() + .peek(calculatedField -> { + calculatedField.setTenantId(ctx.getTenantId()); + calculatedField.setEntityId(savedEntity.getId()); + calculatedField.getConfiguration().getArguments().values().forEach(argument -> { + if (argument.getRefEntityId() != null) { + argument.setRefEntityId(idProvider.getInternalId(argument.getRefEntityId(), ctx.isFinalImportAttempt())); + } + }); + }).toList(); + + for (CalculatedField existingField : existing) { + boolean found = fieldsToSave.stream().anyMatch(importedField -> compareCalculatedFields(existingField, importedField)); + if (!found) { + calculatedFieldService.deleteCalculatedField(ctx.getTenantId(), existingField.getId()); + updated = true; + } + } + + for (CalculatedField calculatedField : fieldsToSave) { + boolean found = existing.stream().anyMatch(existingField -> compareCalculatedFields(existingField, calculatedField)); + if (!found) { + calculatedFieldService.save(calculatedField); + updated = true; + } + } + return updated; + } + + private boolean compareCalculatedFields(CalculatedField existingField, CalculatedField newField) { + CalculatedField oldCopy = new CalculatedField(existingField); + CalculatedField newCopy = new CalculatedField(newField); + oldCopy.setId(null); + newCopy.setId(null); + oldCopy.setVersion(null); + newCopy.setVersion(null); + oldCopy.setCreatedTime(0); + newCopy.setCreatedTime(0); + return oldCopy.equals(newCopy); + } + protected void onEntitySaved(User user, E savedEntity, E oldEntity) throws ThingsboardException { logEntityActionService.logEntityAction(user.getTenantId(), savedEntity.getId(), savedEntity, null, oldEntity == null ? ActionType.ADDED : ActionType.UPDATED, user); } - @SuppressWarnings("unchecked") protected E findExistingEntity(EntitiesImportCtx ctx, E entity, IdProvider idProvider) { return (E) Optional.ofNullable(entitiesService.findEntityByTenantIdAndExternalId(ctx.getTenantId(), entity.getId())) @@ -313,10 +364,10 @@ public abstract class BaseEntityImportService new MissingEntityException(externalId)); } - @SuppressWarnings("unchecked") @RequiredArgsConstructor protected class IdProvider { + private final EntitiesImportCtx ctx; private final EntityImportResult importResult; diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceImportService.java index 61b9839cbd..84e264efdd 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceImportService.java @@ -64,13 +64,18 @@ public class DeviceImportService extends BaseEntityImportService exportData, IdProvider idProvider) { - return deviceProfileService.saveDeviceProfile(deviceProfile); + DeviceProfile saved = deviceProfileService.saveDeviceProfile(deviceProfile); + if (ctx.isFinalImportAttempt() || ctx.getCurrentImportResult().isUpdatedAllExternalIds()) { + importCalculatedFields(ctx, saved, exportData, idProvider); + } + return saved; } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java index eed18abe52..3029e0be70 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java @@ -94,7 +94,6 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.function.Function; -import java.util.stream.Collectors; import static com.google.common.util.concurrent.Futures.transform; import static org.thingsboard.server.common.data.sync.vc.VcUtils.checkBranchName; @@ -304,6 +303,7 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont .updateRelations(config.isLoadRelations()) .saveAttributes(config.isLoadAttributes()) .saveCredentials(config.isLoadCredentials()) + .saveCalculatedFields(config.isLoadCalculatedFields()) .findExistingByName(false) .build()); ctx.setFinalImportAttempt(true); @@ -327,7 +327,7 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont var sw = TbStopWatch.create("before"); List entityTypes = request.getEntityTypes().keySet().stream() - .sorted(exportImportService.getEntityTypeComparatorForImport()).collect(Collectors.toList()); + .sorted(exportImportService.getEntityTypeComparatorForImport()).toList(); for (EntityType entityType : entityTypes) { log.debug("[{}] Loading {} entities", ctx.getTenantId(), entityType); sw.startNew("Entities " + entityType.name()); @@ -362,6 +362,7 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont .updateRelations(config.isLoadRelations()) .saveAttributes(config.isLoadAttributes()) .saveCredentials(config.isLoadCredentials()) + .saveCalculatedFields(config.isLoadCalculatedFields()) .findExistingByName(config.isFindExistingEntityByName()) .build(); } @@ -471,7 +472,7 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont } @Override - public ListenableFuture compareEntityDataToVersion(User user, EntityId entityId, String versionId) throws Exception { + public ListenableFuture compareEntityDataToVersion(User user, EntityId entityId, String versionId) { HasId entity = exportableEntitiesService.findEntityByTenantIdAndId(user.getTenantId(), entityId); if (!(entity instanceof ExportableEntity)) throw new IllegalArgumentException("Unsupported entity type"); @@ -484,6 +485,7 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont .exportRelations(otherVersion.hasRelations()) .exportAttributes(otherVersion.hasAttributes()) .exportCredentials(otherVersion.hasCredentials()) + .exportCalculatedFields(otherVersion.hasCalculatedFields()) .build()); EntityExportData currentVersion; try { @@ -498,12 +500,11 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont @Override public ListenableFuture getEntityDataInfo(User user, EntityId entityId, String versionId) { return Futures.transform(gitServiceQueue.getEntity(user.getTenantId(), versionId, entityId), - entity -> new EntityDataInfo(entity.hasRelations(), entity.hasAttributes(), entity.hasCredentials()), MoreExecutors.directExecutor()); + entity -> new EntityDataInfo(entity.hasRelations(), entity.hasAttributes(), entity.hasCredentials(), entity.hasCalculatedFields()), MoreExecutors.directExecutor()); } - @Override - public ListenableFuture> listBranches(TenantId tenantId) throws Exception { + public ListenableFuture> listBranches(TenantId tenantId) { return gitServiceQueue.listBranches(tenantId); } @@ -527,6 +528,8 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont @Override public ListenableFuture deleteVersionControlSettings(TenantId tenantId) { + log.debug("[{}] Deleting version control settings", tenantId); + repositorySettingsService.delete(tenantId); return gitServiceQueue.clearRepository(tenantId); } diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesExportCtx.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesExportCtx.java index 669dad347a..b3007c6738 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesExportCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesExportCtx.java @@ -69,6 +69,7 @@ public abstract class EntitiesExportCtx { .exportRelations(config.isSaveRelations()) .exportAttributes(config.isSaveAttributes()) .exportCredentials(config.isSaveCredentials()) + .exportCalculatedFields(config.isSaveCalculatedFields()) .build(); } @@ -85,4 +86,5 @@ public abstract class EntitiesExportCtx { log.debug("[{}][{}] Local cache put: {}", internalId.getEntityType(), internalId.getId(), externalId); externalIdMap.put(internalId, externalId != null ? externalId : internalId); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesImportCtx.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesImportCtx.java index ab836fa13d..e24683cd5a 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesImportCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesImportCtx.java @@ -91,6 +91,10 @@ public class EntitiesImportCtx { return getSettings().isSaveCredentials(); } + public boolean isSaveCalculatedFields() { + return getSettings().isSaveCalculatedFields(); + } + public EntityId getInternalId(EntityId externalId) { var result = externalToInternalIdMap.get(externalId); log.debug("[{}][{}] Local cache {} for id", externalId.getEntityType(), externalId.getId(), result != null ? "hit" : "miss"); @@ -140,5 +144,4 @@ public class EntitiesImportCtx { return notFoundIds.contains(externalId); } - } diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntityTypeExportCtx.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntityTypeExportCtx.java index e0a5b40281..55c8a763b0 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntityTypeExportCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntityTypeExportCtx.java @@ -39,6 +39,7 @@ public class EntityTypeExportCtx extends EntitiesExportCtx .exportRelations(config.isSaveRelations()) .exportAttributes(config.isSaveAttributes()) .exportCredentials(config.isSaveCredentials()) + .exportCalculatedFields(config.isSaveCalculatedFields()) .build(); this.overwrite = ObjectUtils.defaultIfNull(config.getSyncStrategy(), defaultSyncStrategy) == SyncStrategy.OVERWRITE; } diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/AbstractSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/AbstractSubscriptionService.java index a5c631b29c..d6fa89a7a2 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/AbstractSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/AbstractSubscriptionService.java @@ -38,6 +38,7 @@ import org.thingsboard.server.service.subscription.SubscriptionManagerService; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; @@ -99,16 +100,27 @@ public abstract class AbstractSubscriptionService extends TbApplicationEventList } protected void addWsCallback(ListenableFuture saveFuture, Consumer callback) { - Futures.addCallback(saveFuture, new FutureCallback() { + addCallback(saveFuture, callback, wsCallBackExecutor); + } + + protected void addCallback(ListenableFuture saveFuture, Consumer callback, Executor executor) { + Futures.addCallback(saveFuture, new FutureCallback<>() { @Override public void onSuccess(@Nullable T result) { callback.accept(result); } @Override - public void onFailure(Throwable t) { - } - }, wsCallBackExecutor); + public void onFailure(Throwable t) {} + }, executor); + } + + protected static Consumer safeCallback(FutureCallback callback) { + if (callback != null) { + return callback::onFailure; + } else { + return throwable -> {}; + } } } diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index 568d80787d..2302446b6b 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -31,29 +31,38 @@ import org.thingsboard.common.util.DonAsynchron; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.rule.engine.api.AttributesDeleteRequest; import org.thingsboard.rule.engine.api.AttributesSaveRequest; +import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; import org.thingsboard.rule.engine.api.TimeseriesDeleteRequest; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; import org.thingsboard.server.common.data.ApiUsageRecordKey; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult; import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.rule.engine.DeviceAttributesEventNotificationMsg; import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.dao.util.KvUtils; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.service.cf.CalculatedFieldQueueService; import org.thingsboard.server.service.entitiy.entityview.TbEntityViewService; +import org.thingsboard.server.service.state.DefaultDeviceStateService; import org.thingsboard.server.service.subscription.TbSubscriptionUtils; import java.util.ArrayList; -import java.util.Comparator; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -63,6 +72,11 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; +import static java.util.Comparator.comparing; +import static java.util.Comparator.comparingLong; +import static java.util.Comparator.naturalOrder; +import static java.util.Comparator.nullsFirst; + /** * Created by ashvayka on 27.03.18. */ @@ -75,6 +89,8 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer private final TbEntityViewService tbEntityViewService; private final TbApiUsageReportClient apiUsageClient; private final TbApiUsageStateService apiUsageStateService; + private final CalculatedFieldQueueService calculatedFieldQueueService; + private final DeviceStateManager deviceStateManager; private ExecutorService tsCallBackExecutor; @@ -85,12 +101,16 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer TimeseriesService tsService, @Lazy TbEntityViewService tbEntityViewService, TbApiUsageReportClient apiUsageClient, - TbApiUsageStateService apiUsageStateService) { + TbApiUsageStateService apiUsageStateService, + CalculatedFieldQueueService calculatedFieldQueueService, + DeviceStateManager deviceStateManager) { this.attrService = attrService; this.tsService = tsService; this.tbEntityViewService = tbEntityViewService; this.apiUsageClient = apiUsageClient; this.apiUsageStateService = apiUsageStateService; + this.calculatedFieldQueueService = calculatedFieldQueueService; + this.deviceStateManager = deviceStateManager; } @PostConstruct @@ -120,10 +140,9 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer boolean sysTenant = TenantId.SYS_TENANT_ID.equals(tenantId) || tenantId == null; if (sysTenant || !request.getStrategy().saveTimeseries() || apiUsageStateService.getApiUsageState(tenantId).isDbStorageEnabled()) { KvUtils.validate(request.getEntries(), valueNoXssValidation); - ListenableFuture future = saveTimeseriesInternal(request); + ListenableFuture future = saveTimeseriesInternal(request); if (request.getStrategy().saveTimeseries()) { - FutureCallback callback = getApiUsageCallback(tenantId, request.getCustomerId(), sysTenant, request.getCallback()); - Futures.addCallback(future, callback, tsCallBackExecutor); + Futures.addCallback(future, getApiUsageCallback(tenantId, request.getCustomerId(), sysTenant), tsCallBackExecutor); } } else { request.getCallback().onFailure(new RuntimeException("DB storage writes are disabled due to API limits!")); @@ -131,29 +150,37 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer } @Override - public ListenableFuture saveTimeseriesInternal(TimeseriesSaveRequest request) { + public ListenableFuture saveTimeseriesInternal(TimeseriesSaveRequest request) { TenantId tenantId = request.getTenantId(); EntityId entityId = request.getEntityId(); TimeseriesSaveRequest.Strategy strategy = request.getStrategy(); - ListenableFuture saveFuture; + ListenableFuture resultFuture; + if (strategy.saveTimeseries() && strategy.saveLatest()) { - saveFuture = tsService.save(tenantId, entityId, request.getEntries(), request.getTtl()); + resultFuture = tsService.save(tenantId, entityId, request.getEntries(), request.getTtl()); } else if (strategy.saveLatest()) { - saveFuture = Futures.transform(tsService.saveLatest(tenantId, entityId, request.getEntries()), result -> 0, MoreExecutors.directExecutor()); + resultFuture = tsService.saveLatest(tenantId, entityId, request.getEntries()); } else if (strategy.saveTimeseries()) { - saveFuture = tsService.saveWithoutLatest(tenantId, entityId, request.getEntries(), request.getTtl()); + resultFuture = tsService.saveWithoutLatest(tenantId, entityId, request.getEntries(), request.getTtl()); } else { - saveFuture = Futures.immediateFuture(0); + resultFuture = Futures.immediateFuture(TimeseriesSaveResult.EMPTY); } - addMainCallback(saveFuture, request.getCallback()); + addMainCallback(resultFuture, result -> { + if (strategy.processCalculatedFields()) { + calculatedFieldQueueService.pushRequestToQueue(request, result, request.getCallback()); + } else { + request.getCallback().onSuccess(null); + } + }, t -> request.getCallback().onFailure(t)); + if (strategy.sendWsUpdate()) { - addWsCallback(saveFuture, success -> onTimeSeriesUpdate(tenantId, entityId, request.getEntries())); + addWsCallback(resultFuture, success -> onTimeSeriesUpdate(tenantId, entityId, request.getEntries())); } if (strategy.saveLatest()) { copyLatestToEntityViews(tenantId, entityId, request.getEntries()); } - return saveFuture; + return resultFuture; } @Override @@ -165,9 +192,68 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer @Override public void saveAttributesInternal(AttributesSaveRequest request) { log.trace("Executing saveInternal [{}]", request); - ListenableFuture> saveFuture = attrService.save(request.getTenantId(), request.getEntityId(), request.getScope(), request.getEntries()); - addMainCallback(saveFuture, request.getCallback()); - addWsCallback(saveFuture, success -> onAttributesUpdate(request.getTenantId(), request.getEntityId(), request.getScope().name(), request.getEntries(), request.isNotifyDevice())); + TenantId tenantId = request.getTenantId(); + EntityId entityId = request.getEntityId(); + AttributesSaveRequest.Strategy strategy = request.getStrategy(); + ListenableFuture> resultFuture; + + if (strategy.saveAttributes()) { + resultFuture = attrService.save(tenantId, entityId, request.getScope(), request.getEntries()); + } else { + resultFuture = Futures.immediateFuture(Collections.emptyList()); + } + + addMainCallback(resultFuture, result -> { + if (strategy.processCalculatedFields()) { + calculatedFieldQueueService.pushRequestToQueue(request, result, request.getCallback()); + } else { + request.getCallback().onSuccess(null); + } + }, t -> request.getCallback().onFailure(t)); + + if (shouldSendSharedAttributesUpdatedNotification(request)) { + addMainCallback(resultFuture, success -> clusterService.pushMsgToCore( + DeviceAttributesEventNotificationMsg.onUpdate(tenantId, new DeviceId(entityId.getId()), DataConstants.SHARED_SCOPE, request.getEntries()), null + )); + } + + if (shouldCheckForInactivityTimeoutUpdates(request)) { + findNewInactivityTimeout(request.getEntries()).ifPresent(newInactivityTimeout -> + addMainCallback(resultFuture, success -> deviceStateManager.onDeviceInactivityTimeoutUpdate( + tenantId, new DeviceId(entityId.getId()), newInactivityTimeout, TbCallback.EMPTY) + ) + ); + } + + if (strategy.sendWsUpdate()) { + addWsCallback(resultFuture, success -> onAttributesUpdate(tenantId, entityId, request.getScope().name(), request.getEntries())); + } + } + + private static boolean shouldSendSharedAttributesUpdatedNotification(AttributesSaveRequest request) { + return request.getStrategy().saveAttributes() && shouldSendSharedAttributesNotification(request.getEntityId(), request.getScope(), request.isNotifyDevice()); + } + + private static boolean shouldCheckForInactivityTimeoutUpdates(AttributesSaveRequest request) { + return request.getStrategy().saveAttributes() + && request.getEntityId().getEntityType() == EntityType.DEVICE + && request.getScope() == AttributeScope.SERVER_SCOPE; + } + + private static Optional findNewInactivityTimeout(List entries) { + return entries.stream() + .filter(entry -> Objects.equals(DefaultDeviceStateService.INACTIVITY_TIMEOUT, entry.getKey())) + // Select the entry with the highest version, or if the versions are equal, the one with the most recent update timestamp + .max(comparing(AttributeKvEntry::getVersion, nullsFirst(naturalOrder())).thenComparingLong(AttributeKvEntry::getLastUpdateTs)) + .map(DefaultTelemetrySubscriptionService::parseAsLong); + } + + private static long parseAsLong(KvEntry kve) { + try { + return Long.parseLong(kve.getValueAsString()); + } catch (NumberFormatException e) { + return 0L; + } } @Override @@ -178,9 +264,45 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer @Override public void deleteAttributesInternal(AttributesDeleteRequest request) { - ListenableFuture> deleteFuture = attrService.removeAll(request.getTenantId(), request.getEntityId(), request.getScope(), request.getKeys()); - addMainCallback(deleteFuture, request.getCallback()); - addWsCallback(deleteFuture, success -> onAttributesDelete(request.getTenantId(), request.getEntityId(), request.getScope().name(), request.getKeys(), request.isNotifyDevice())); + TenantId tenantId = request.getTenantId(); + EntityId entityId = request.getEntityId(); + + ListenableFuture> deleteFuture = attrService.removeAll(tenantId, entityId, request.getScope(), request.getKeys()); + + addMainCallback(deleteFuture, + result -> calculatedFieldQueueService.pushRequestToQueue(request, result, request.getCallback()), + t -> request.getCallback().onFailure(t) + ); + + if (shouldSendSharedAttributesDeletedNotification(request)) { + addMainCallback(deleteFuture, success -> clusterService.pushMsgToCore( + DeviceAttributesEventNotificationMsg.onDelete(tenantId, new DeviceId(entityId.getId()), DataConstants.SHARED_SCOPE, request.getKeys()), null + )); + } + + if (inactivityTimeoutDeleted(request)) { + addMainCallback(deleteFuture, success -> deviceStateManager.onDeviceInactivityTimeoutUpdate( + tenantId, new DeviceId(entityId.getId()), 0L, TbCallback.EMPTY) + ); + } + + addWsCallback(deleteFuture, success -> onAttributesDelete(tenantId, entityId, request.getScope().name(), request.getKeys())); + } + + private static boolean shouldSendSharedAttributesDeletedNotification(AttributesDeleteRequest request) { + return shouldSendSharedAttributesNotification(request.getEntityId(), request.getScope(), request.isNotifyDevice()); + } + + private static boolean shouldSendSharedAttributesNotification(EntityId entityId, AttributeScope scope, boolean notifyDevice) { + return entityId.getEntityType() == EntityType.DEVICE + && scope == AttributeScope.SHARED_SCOPE + && notifyDevice; + } + + private static boolean inactivityTimeoutDeleted(AttributesDeleteRequest request) { + return request.getEntityId().getEntityType() == EntityType.DEVICE + && request.getScope() == AttributeScope.SERVER_SCOPE + && request.getKeys().stream().anyMatch(key -> Objects.equals(DefaultDeviceStateService.INACTIVITY_TIMEOUT, key)); } @Override @@ -199,10 +321,14 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer deleteFuture = tsService.remove(request.getTenantId(), request.getEntityId(), request.getDeleteHistoryQueries()); addWsCallback(deleteFuture, result -> onTimeSeriesDelete(request.getTenantId(), request.getEntityId(), request.getKeys(), result)); } - addMainCallback(deleteFuture, __ -> request.getCallback().onSuccess(request.getKeys()), request.getCallback()::onFailure); + DonAsynchron.withCallback(deleteFuture, result -> { + calculatedFieldQueueService.pushRequestToQueue(request, request.getKeys(), getCalculatedFieldCallback(request.getCallback(), request.getKeys())); + }, safeCallback(getCalculatedFieldCallback(request.getCallback(), request.getKeys())), tsCallBackExecutor); } else { ListenableFuture> deleteFuture = tsService.removeAllLatest(request.getTenantId(), request.getEntityId()); - addMainCallback(deleteFuture, request.getCallback()::onSuccess, request.getCallback()::onFailure); + DonAsynchron.withCallback(deleteFuture, result -> { + calculatedFieldQueueService.pushRequestToQueue(request, request.getKeys(), getCalculatedFieldCallback(request.getCallback(), result)); + }, safeCallback(getCalculatedFieldCallback(request.getCallback(), request.getKeys())), tsCallBackExecutor); } } @@ -228,7 +354,7 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer if (entries != null) { Optional tsKvEntry = entries.stream() .filter(entry -> entry.getTs() > startTs && entry.getTs() <= endTs) - .max(Comparator.comparingLong(TsKvEntry::getTs)); + .max(comparingLong(TsKvEntry::getTs)); tsKvEntry.ifPresent(entityViewLatest::add); } } @@ -261,28 +387,22 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer } } - private void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List attributes, boolean notifyDevice) { - forwardToSubscriptionManagerService(tenantId, entityId, subscriptionManagerService -> { - subscriptionManagerService.onAttributesUpdate(tenantId, entityId, scope, attributes, notifyDevice, TbCallback.EMPTY); - }, () -> { - return TbSubscriptionUtils.toAttributesUpdateProto(tenantId, entityId, scope, attributes); - }); + private void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List attributes) { + forwardToSubscriptionManagerService(tenantId, entityId, + subscriptionManagerService -> subscriptionManagerService.onAttributesUpdate(tenantId, entityId, scope, attributes, TbCallback.EMPTY), + () -> TbSubscriptionUtils.toAttributesUpdateProto(tenantId, entityId, scope, attributes)); } - private void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List keys, boolean notifyDevice) { - forwardToSubscriptionManagerService(tenantId, entityId, subscriptionManagerService -> { - subscriptionManagerService.onAttributesDelete(tenantId, entityId, scope, keys, notifyDevice, TbCallback.EMPTY); - }, () -> { - return TbSubscriptionUtils.toAttributesDeleteProto(tenantId, entityId, scope, keys, notifyDevice); - }); + private void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List keys) { + forwardToSubscriptionManagerService(tenantId, entityId, + subscriptionManagerService -> subscriptionManagerService.onAttributesDelete(tenantId, entityId, scope, keys, TbCallback.EMPTY), + () -> TbSubscriptionUtils.toAttributesDeleteProto(tenantId, entityId, scope, keys)); } private void onTimeSeriesUpdate(TenantId tenantId, EntityId entityId, List ts) { - forwardToSubscriptionManagerService(tenantId, entityId, subscriptionManagerService -> { - subscriptionManagerService.onTimeSeriesUpdate(tenantId, entityId, ts, TbCallback.EMPTY); - }, () -> { - return TbSubscriptionUtils.toTimeseriesUpdateProto(tenantId, entityId, ts); - }); + forwardToSubscriptionManagerService(tenantId, entityId, + subscriptionManagerService -> subscriptionManagerService.onTimeSeriesUpdate(tenantId, entityId, ts, TbCallback.EMPTY), + () -> TbSubscriptionUtils.toTimeseriesUpdateProto(tenantId, entityId, ts)); } private void onTimeSeriesDelete(TenantId tenantId, EntityId entityId, List keys, List ts) { @@ -302,9 +422,7 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer subscriptionManagerService.onTimeSeriesUpdate(tenantId, entityId, updated, TbCallback.EMPTY); subscriptionManagerService.onTimeSeriesDelete(tenantId, entityId, deleted, TbCallback.EMPTY); - }, () -> { - return TbSubscriptionUtils.toTimeseriesDeleteProto(tenantId, entityId, keys); - }); + }, () -> TbSubscriptionUtils.toTimeseriesDeleteProto(tenantId, entityId, keys)); } private void addMainCallback(ListenableFuture saveFuture, final FutureCallback callback) { @@ -312,6 +430,10 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer addMainCallback(saveFuture, result -> callback.onSuccess(null), callback::onFailure); } + private void addMainCallback(ListenableFuture saveFuture, Consumer onSuccess) { + addMainCallback(saveFuture, onSuccess, null); + } + private void addMainCallback(ListenableFuture saveFuture, Consumer onSuccess, Consumer onFailure) { DonAsynchron.withCallback(saveFuture, onSuccess, onFailure, tsCallBackExecutor); } @@ -322,19 +444,31 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer } } - private FutureCallback getApiUsageCallback(TenantId tenantId, CustomerId customerId, boolean sysTenant, FutureCallback callback) { + private FutureCallback getApiUsageCallback(TenantId tenantId, CustomerId customerId, boolean sysTenant) { return new FutureCallback<>() { @Override - public void onSuccess(Integer result) { - if (!sysTenant && result != null && result > 0) { - apiUsageClient.report(tenantId, customerId, ApiUsageRecordKey.STORAGE_DP_COUNT, result); + public void onSuccess(TimeseriesSaveResult result) { + Integer dataPoints = result.getDataPoints(); + if (!sysTenant && dataPoints != null && dataPoints > 0) { + apiUsageClient.report(tenantId, customerId, ApiUsageRecordKey.STORAGE_DP_COUNT, dataPoints); } - callback.onSuccess(null); + } + + @Override + public void onFailure(Throwable t) {} + }; + } + + private FutureCallback getCalculatedFieldCallback(FutureCallback> originalCallback, List keys) { + return new FutureCallback<>() { + @Override + public void onSuccess(Void unused) { + originalCallback.onSuccess(keys); } @Override public void onFailure(Throwable t) { - callback.onFailure(t); + originalCallback.onFailure(t); } }; } diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/InternalTelemetryService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/InternalTelemetryService.java index 380617934d..8a76aa1d14 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/InternalTelemetryService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/InternalTelemetryService.java @@ -21,13 +21,14 @@ import org.thingsboard.rule.engine.api.AttributesSaveRequest; import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; import org.thingsboard.rule.engine.api.TimeseriesDeleteRequest; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; +import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; /** * Created by ashvayka on 27.03.18. */ public interface InternalTelemetryService extends RuleEngineTelemetryService { - ListenableFuture saveTimeseriesInternal(TimeseriesSaveRequest request); + ListenableFuture saveTimeseriesInternal(TimeseriesSaveRequest request); void saveAttributesInternal(AttributesSaveRequest request); diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index 2c8c942ba3..7fee411704 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -351,7 +351,7 @@ public class DefaultTransportApiService implements TransportApiService { device.setAdditionalInfo(additionalInfo); device = deviceService.saveDevice(device); - relationService.saveRelation(TenantId.SYS_TENANT_ID, new EntityRelation(gateway.getId(), device.getId(), "Created")); + relationService.saveRelation(tenantId, new EntityRelation(gateway.getId(), device.getId(), "Created")); TbMsgMetaData metaData = new TbMsgMetaData(); CustomerId customerId = gateway.getCustomerId(); diff --git a/application/src/main/java/org/thingsboard/server/service/transport/TbCoreTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/TbCoreTransportApiService.java index 62913a187b..4b60ec923a 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/TbCoreTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/TbCoreTransportApiService.java @@ -93,7 +93,8 @@ public class TbCoreTransportApiService { @AfterStartUp(order = AfterStartUp.REGULAR_SERVICE) public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) { log.info("Received application ready event. Starting polling for events."); - transportApiTemplate.init(transportApiService); + transportApiTemplate.subscribe(); + transportApiTemplate.launch(transportApiService); } @PreDestroy diff --git a/application/src/main/java/org/thingsboard/server/service/ttl/KafkaEdgeTopicsCleanUpService.java b/application/src/main/java/org/thingsboard/server/service/ttl/KafkaEdgeTopicsCleanUpService.java index 5a74f9f69a..73712542fd 100644 --- a/application/src/main/java/org/thingsboard/server/service/ttl/KafkaEdgeTopicsCleanUpService.java +++ b/application/src/main/java/org/thingsboard/server/service/ttl/KafkaEdgeTopicsCleanUpService.java @@ -53,8 +53,6 @@ import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAS @ConditionalOnExpression("'${queue.type:null}'=='kafka' && ${edges.enabled:true} && ${sql.ttl.edge_events.edge_events_ttl:0} > 0") public class KafkaEdgeTopicsCleanUpService extends AbstractCleanUpService { - private static final String EDGE_EVENT_TOPIC_NAME = "tb_edge_event.notifications."; - private final TopicService topicService; private final TenantService tenantService; private final EdgeService edgeService; @@ -64,6 +62,9 @@ public class KafkaEdgeTopicsCleanUpService extends AbstractCleanUpService { @Value("${sql.ttl.edge_events.edge_events_ttl:2628000}") private long ttlSeconds; + @Value("${queue.edge.event-notifications-topic:tb_edge_event.notifications}") + private String tbEdgeEventNotificationsTopic; + public KafkaEdgeTopicsCleanUpService(PartitionService partitionService, EdgeService edgeService, TenantService tenantService, AttributesService attributesService, TopicService topicService, TbKafkaSettings kafkaSettings, TbKafkaTopicConfigs kafkaTopicConfigs) { @@ -86,7 +87,7 @@ public class KafkaEdgeTopicsCleanUpService extends AbstractCleanUpService { return; } - String edgeTopicPrefix = topicService.buildTopicName(EDGE_EVENT_TOPIC_NAME); + String edgeTopicPrefix = topicService.buildTopicName(tbEdgeEventNotificationsTopic); List matchingTopics = topics.stream().filter(topic -> topic.startsWith(edgeTopicPrefix)).toList(); if (matchingTopics.isEmpty()) { log.debug("No matching topics found with prefix [{}]. Skipping cleanup.", edgeTopicPrefix); @@ -147,7 +148,7 @@ public class KafkaEdgeTopicsCleanUpService extends AbstractCleanUpService { try { String remaining = topic.substring(prefix.length()); String[] parts = remaining.split("\\."); - TenantId tenantId = new TenantId(UUID.fromString(parts[0])); + TenantId tenantId = TenantId.fromUUID(UUID.fromString(parts[0])); EdgeId edgeId = new EdgeId(UUID.fromString(parts[1])); tenantEdgeMap.computeIfAbsent(tenantId, id -> new ArrayList<>()).add(edgeId); } catch (Exception e) { diff --git a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java index 90f6b36e0f..7288a8bac9 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java @@ -229,6 +229,7 @@ public class DefaultWebSocketService implements WebSocketService { } catch (TbRateLimitsException e) { log.debug("{} Failed to handle WS cmd: {}", sessionRef, cmd, e); } catch (Exception e) { + sendError(sessionRef, cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, e.getMessage()); log.error("{} Failed to handle WS cmd: {}", sessionRef, cmd, e); } } diff --git a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java new file mode 100644 index 0000000000..77080c28c8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java @@ -0,0 +1,152 @@ +/** + * 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. + */ +package org.thingsboard.server.utils; + +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.BasicKvEntry; +import org.thingsboard.server.common.util.KvProtoUtil; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldEntityCtxIdProto; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldIdProto; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; +import org.thingsboard.server.gen.transport.TransportProtos.SingleValueArgumentProto; +import org.thingsboard.server.gen.transport.TransportProtos.TsDoubleValProto; +import org.thingsboard.server.gen.transport.TransportProtos.TsRollingArgumentProto; +import org.thingsboard.server.gen.transport.TransportProtos.TsValueProto; +import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.TsRollingArgumentEntry; + +import java.util.Optional; +import java.util.TreeMap; +import java.util.UUID; + +public class CalculatedFieldUtils { + + public static CalculatedFieldIdProto toProto(CalculatedFieldId cfId) { + return CalculatedFieldIdProto.newBuilder() + .setCalculatedFieldIdMSB(cfId.getId().getMostSignificantBits()) + .setCalculatedFieldIdLSB(cfId.getId().getLeastSignificantBits()) + .build(); + } + + public static CalculatedFieldEntityCtxIdProto toProto(CalculatedFieldEntityCtxId ctxId) { + return CalculatedFieldEntityCtxIdProto.newBuilder() + .setTenantIdMSB(ctxId.tenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(ctxId.tenantId().getId().getLeastSignificantBits()) + .setCalculatedFieldIdMSB(ctxId.cfId().getId().getMostSignificantBits()) + .setCalculatedFieldIdLSB(ctxId.cfId().getId().getLeastSignificantBits()) + .setEntityType(ctxId.entityId().getEntityType().name()) + .setEntityIdMSB(ctxId.entityId().getId().getMostSignificantBits()) + .setEntityIdLSB(ctxId.entityId().getId().getLeastSignificantBits()) + .build(); + } + + public static CalculatedFieldEntityCtxId fromProto(CalculatedFieldEntityCtxIdProto ctxIdProto) { + TenantId tenantId = TenantId.fromUUID(new UUID(ctxIdProto.getTenantIdMSB(), ctxIdProto.getTenantIdLSB())); + EntityId entityId = EntityIdFactory.getByTypeAndUuid(ctxIdProto.getEntityType(), new UUID(ctxIdProto.getEntityIdMSB(), ctxIdProto.getEntityIdLSB())); + CalculatedFieldId calculatedFieldId = new CalculatedFieldId(new UUID(ctxIdProto.getCalculatedFieldIdMSB(), ctxIdProto.getCalculatedFieldIdLSB())); + return new CalculatedFieldEntityCtxId(tenantId, calculatedFieldId, entityId); + } + + public static CalculatedFieldStateProto toProto(CalculatedFieldEntityCtxId stateId, CalculatedFieldState state) { + CalculatedFieldStateProto.Builder builder = CalculatedFieldStateProto.newBuilder() + .setId(toProto(stateId)) + .setType(state.getType().name()); + + state.getArguments().forEach((argName, argEntry) -> { + if (argEntry instanceof SingleValueArgumentEntry singleValueArgumentEntry) { + builder.addSingleValueArguments(toSingleValueArgumentProto(argName, singleValueArgumentEntry)); + } else if (argEntry instanceof TsRollingArgumentEntry rollingArgumentEntry) { + builder.addRollingValueArguments(toRollingArgumentProto(argName, rollingArgumentEntry)); + } + }); + return builder.build(); + } + + public static SingleValueArgumentProto toSingleValueArgumentProto(String argName, SingleValueArgumentEntry entry) { + SingleValueArgumentProto.Builder builder = SingleValueArgumentProto.newBuilder() + .setArgName(argName); + + if (entry.getKvEntryValue() != null) { + builder.setValue(KvProtoUtil.toTsValueProto(entry.getTs(), entry.getKvEntryValue())); + } + + Optional.ofNullable(entry.getVersion()).ifPresent(builder::setVersion); + + return builder.build(); + } + + public static TsRollingArgumentProto toRollingArgumentProto(String argName, TsRollingArgumentEntry entry) { + TsRollingArgumentProto.Builder builder = TsRollingArgumentProto.newBuilder() + .setKey(argName) + .setLimit(entry.getLimit()) + .setTimeWindow(entry.getTimeWindow()); + + entry.getTsRecords().forEach((ts, value) -> builder.addTsValue(TsDoubleValProto.newBuilder().setTs(ts).setValue(value).build())); + + return builder.build(); + } + + public static CalculatedFieldState fromProto(CalculatedFieldStateProto proto) { + if (StringUtils.isEmpty(proto.getType())) { + return null; + } + + CalculatedFieldType type = CalculatedFieldType.valueOf(proto.getType()); + + CalculatedFieldState state = switch (type) { + case SIMPLE -> new SimpleCalculatedFieldState(); + case SCRIPT -> new ScriptCalculatedFieldState(); + }; + + proto.getSingleValueArgumentsList().forEach(argProto -> + state.getArguments().put(argProto.getArgName(), fromSingleValueArgumentProto(argProto))); + + if (CalculatedFieldType.SCRIPT.equals(type)) { + proto.getRollingValueArgumentsList().forEach(argProto -> + state.getArguments().put(argProto.getKey(), fromRollingArgumentProto(argProto))); + } + + return state; + } + + public static SingleValueArgumentEntry fromSingleValueArgumentProto(SingleValueArgumentProto proto) { + if (!proto.hasValue()) { + return new SingleValueArgumentEntry(); + } + TsValueProto tsValueProto = proto.getValue(); + return new SingleValueArgumentEntry( + tsValueProto.getTs(), + (BasicKvEntry) KvProtoUtil.fromTsValueProto(proto.getArgName(), tsValueProto), + proto.getVersion() + ); + } + + public static TsRollingArgumentEntry fromRollingArgumentProto(TsRollingArgumentProto proto) { + TreeMap tsRecords = new TreeMap<>(); + proto.getTsValueList().forEach(tsValueProto -> tsRecords.put(tsValueProto.getTs(), tsValueProto.getValue())); + return new TsRollingArgumentEntry(tsRecords, proto.getLimit(), proto.getTimeWindow()); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/utils/DebugModeRateLimitsConfig.java b/application/src/main/java/org/thingsboard/server/utils/DebugModeRateLimitsConfig.java new file mode 100644 index 0000000000..99ef186a56 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/utils/DebugModeRateLimitsConfig.java @@ -0,0 +1,36 @@ +/** + * 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. + */ +package org.thingsboard.server.utils; + +import lombok.Data; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +@Data +public class DebugModeRateLimitsConfig { + + @Value("${actors.rule.chain.debug_mode_rate_limits_per_tenant.enabled:true}") + private boolean ruleChainDebugPerTenantLimitsEnabled; + @Value("${actors.rule.chain.debug_mode_rate_limits_per_tenant.configuration:50000:3600}") + private String ruleChainDebugPerTenantLimitsConfiguration; + + @Value("${actors.calculated_fields.debug_mode_rate_limits_per_tenant.enabled:true}") + private boolean calculatedFieldDebugPerTenantLimitsEnabled; + @Value("${actors.calculated_fields.debug_mode_rate_limits_per_tenant.configuration:50000:3600}") + private String calculatedFieldDebugPerTenantLimitsConfiguration; + +} diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 7b781c0e3f..721d23b700 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -187,7 +187,9 @@ usage: # Enable/Disable the collection of API usage statistics on a customer level enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Statistics reporting interval, set to send summarized data every 10 seconds by default - interval: "${USAGE_STATS_REPORT_INTERVAL:10}" + interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Amount of statistic messages in pack + pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" check: # Interval of checking the start of the next cycle and re-enabling the blocked tenants/customers cycle: "${USAGE_STATS_CHECK_CYCLE:60000}" @@ -437,6 +439,8 @@ actors: device_dispatcher_pool_size: "${ACTORS_SYSTEM_DEVICE_DISPATCHER_POOL_SIZE:4}" # Thread pool size for actor system dispatcher that process messages for device actors rule_dispatcher_pool_size: "${ACTORS_SYSTEM_RULE_DISPATCHER_POOL_SIZE:8}" # Thread pool size for actor system dispatcher that process messages for rule engine (chain/node) actors edge_dispatcher_pool_size: "${ACTORS_SYSTEM_EDGE_DISPATCHER_POOL_SIZE:4}" # Thread pool size for actor system dispatcher that process messages for edge actors + cfm_dispatcher_pool_size: "${ACTORS_SYSTEM_CFM_DISPATCHER_POOL_SIZE:2}" # Thread pool size for actor system dispatcher that process messages for CalculatedField manager actors + cfe_dispatcher_pool_size: "${ACTORS_SYSTEM_CFE_DISPATCHER_POOL_SIZE:8}" # Thread pool size for actor system dispatcher that process messages for CalculatedField entity actors tenant: create_components_on_init: "${ACTORS_TENANT_CREATE_COMPONENTS_ON_INIT:true}" # Create components in initialization session: @@ -500,10 +504,16 @@ actors: statistics: # Enable/disable actor statistics enabled: "${ACTORS_STATISTICS_ENABLED:true}" - # Frequency of printing the JS executor statistics - js_print_interval_ms: "${ACTORS_JS_STATISTICS_PRINT_INTERVAL_MS:10000}" # Actors statistic persistence frequency in milliseconds persist_frequency: "${ACTORS_STATISTICS_PERSIST_FREQUENCY:3600000}" + calculated_fields: + debug_mode_rate_limits_per_tenant: + # Enable/Disable the rate limit of persisted debug events for all calculated fields per tenant + enabled: "${ACTORS_CALCULATED_FIELD_DEBUG_MODE_RATE_LIMITS_PER_TENANT_ENABLED:true}" + # The value of DEBUG mode rate limit. By default, no more than 50 thousand events per hour + configuration: "${ACTORS_CALCULATED_FIELD_DEBUG_MODE_RATE_LIMITS_PER_TENANT_CONFIGURATION:50000:3600}" + # Time in seconds to receive calculation result. + calculation_timeout: "${ACTORS_CALCULATION_TIMEOUT_SEC:5}" debug: settings: @@ -807,7 +817,7 @@ spring: events: # Enable dedicated datasource (a separate database) for events and audit logs. # Before enabling this, make sure you have set up the following tables in the new DB: - # error_event, lc_event, rule_chain_debug_event, rule_node_debug_event, stats_event, audit_log + # error_event, lc_event, rule_chain_debug_event, rule_node_debug_event, stats_event, audit_log, cf_debug_event enabled: "${SPRING_DEDICATED_EVENTS_DATASOURCE_ENABLED:false}" # Database driver for Spring JPA for events datasource driverClassName: "${SPRING_EVENTS_DATASOURCE_DRIVER_CLASS_NAME:org.postgresql.Driver}" @@ -848,6 +858,7 @@ audit-log: "edge": "${AUDIT_LOG_MASK_EDGE:W}" # Edge logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation "tb_resource": "${AUDIT_LOG_MASK_RESOURCE:W}" # TB resource logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation "ota_package": "${AUDIT_LOG_MASK_OTA_PACKAGE:W}" # Ota package logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation + "calculated_field": "${AUDIT_LOG_MASK_CALCULATED_FIELD:W}" # Calculated field logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation sink: # Type of external sink. possible options: none, elasticsearch type: "${AUDIT_LOG_SINK_TYPE:none}" @@ -1273,7 +1284,7 @@ transport: # URL of gateways dashboard repository repository_url: "${TB_GATEWAY_DASHBOARD_SYNC_REPOSITORY_URL:https://github.com/thingsboard/gateway-management-extensions-dist.git}" # Branch of gateways dashboard repository to work with - branch: "${TB_GATEWAY_DASHBOARD_SYNC_BRANCH:}" + branch: "${TB_GATEWAY_DASHBOARD_SYNC_BRANCH:release/4.0.0}" # Fetch frequency in hours for gateways dashboard repository fetch_frequency: "${TB_GATEWAY_DASHBOARD_SYNC_FETCH_FREQUENCY:24}" @@ -1589,6 +1600,16 @@ queue: - key: max.poll.records # Amount of records to be returned in a single poll. For Housekeeper reprocessing topic, we should consume messages (tasks) one by one value: "${TB_QUEUE_KAFKA_HOUSEKEEPER_REPROCESSING_MAX_POLL_RECORDS:1}" + edqs.events: + # Key-value properties for Kafka consumer for edqs.events topic + - key: max.poll.records + # Max poll records for edqs.events topic + value: "${TB_QUEUE_KAFKA_EDQS_EVENTS_MAX_POLL_RECORDS:512}" + edqs.state: + # Key-value properties for Kafka consumer for edqs.state topic + - key: max.poll.records + # Max poll records for edqs.state topic + value: "${TB_QUEUE_KAFKA_EDQS_STATE_MAX_POLL_RECORDS:512}" other-inline: "${TB_QUEUE_KAFKA_OTHER_PROPERTIES:}" # In this section you can specify custom parameters (semicolon separated) for Kafka consumer/producer/admin # Example "metrics.recording.level:INFO;metrics.sample.window.ms:30000" other: # DEPRECATED. In this section, you can specify custom parameters for Kafka consumer/producer and expose the env variables to configure outside # - key: "request.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms @@ -1618,6 +1639,16 @@ queue: edge: "${TB_QUEUE_KAFKA_EDGE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Edge event topic edge-event: "${TB_QUEUE_KAFKA_EDGE_EVENT_TOPIC_PROPERTIES:retention.ms:2592000000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + # Kafka properties for Calculated Field topics + calculated-field: "${TB_QUEUE_KAFKA_CF_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + # Kafka properties for Calculated Field State topics + calculated-field-state: "${TB_QUEUE_KAFKA_CF_STATE_TOPIC_PROPERTIES:retention.ms:-1;segment.bytes:52428800;retention.bytes:104857600000;partitions:1;min.insync.replicas:1;cleanup.policy:compact}" + # Kafka properties for EDQS events topics. Partitions number must be the same as queue.edqs.partitions + edqs-events: "${TB_QUEUE_KAFKA_EDQS_EVENTS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:-1;partitions:12;min.insync.replicas:1}" + # Kafka properties for EDQS requests topic (default: 3 minutes retention). Partitions number must be the same as queue.edqs.partitions + edqs-requests: "${TB_QUEUE_KAFKA_EDQS_REQUESTS_TOPIC_PROPERTIES:retention.ms:180000;segment.bytes:52428800;retention.bytes:1048576000;partitions:12;min.insync.replicas:1}" + # Kafka properties for EDQS state topic (infinite retention, compaction). Partitions number must be the same as queue.edqs.partitions + edqs-state: "${TB_QUEUE_KAFKA_EDQS_STATE_TOPIC_PROPERTIES:retention.ms:-1;segment.bytes:52428800;retention.bytes:-1;partitions:12;min.insync.replicas:1;cleanup.policy:compact}" consumer-stats: # Prints lag between consumer group offset and last messages offset in Kafka topics enabled: "${TB_QUEUE_KAFKA_CONSUMER_STATS_ENABLED:true}" @@ -1647,6 +1678,8 @@ queue: core: # Default topic name topic: "${TB_QUEUE_CORE_TOPIC:tb_core}" + # For high-priority notifications that require minimum latency and processing time + notifications_topic: "${TB_QUEUE_CORE_NOTIFICATIONS_TOPIC:tb_core.notifications}" # Interval in milliseconds to poll messages by Core microservices poll-interval: "${TB_QUEUE_CORE_POLL_INTERVAL_MS:25}" # Amount of partitions used by Core microservices @@ -1691,7 +1724,41 @@ queue: enabled: "${TB_HOUSEKEEPER_STATS_ENABLED:true}" # Statistics printing interval for Housekeeper print-interval-ms: "${TB_HOUSEKEEPER_STATS_PRINT_INTERVAL_MS:60000}" - + edqs: + sync: + # Enable/disable EDQS synchronization + enabled: "${TB_EDQS_SYNC_ENABLED:false}" + # Batch size of entities being synced with EDQS + entity_batch_size: "${TB_EDQS_SYNC_ENTITY_BATCH_SIZE:10000}" + # Batch size of timeseries data being synced with EDQS + ts_batch_size: "${TB_EDQS_SYNC_TS_BATCH_SIZE:10000}" + api: + # Whether to forward entity data query requests to EDQS (otherwise use PostgreSQL implementation) + supported: "${TB_EDQS_API_SUPPORTED:false}" + # Whether to auto-enable EDQS API (if queue.edqs.api.supported is true) when sync of data to Kafka is finished + auto_enable: "${TB_EDQS_API_AUTO_ENABLE:true}" + # Mode of EDQS: local (for monolith) or remote (with separate EDQS microservices) + mode: "${TB_EDQS_MODE:local}" + local: + # Path to RocksDB for EDQS backup when running in local mode + rocksdb_path: "${TB_EDQS_ROCKSDB_PATH:${user.home}/.rocksdb/edqs}" + # Number of partitions for EDQS topics + partitions: "${TB_EDQS_PARTITIONS:12}" + # EDQS partitioning strategy: tenant (partition is resolved by tenant id) or none (no specific strategy, resolving by message key) + partitioning_strategy: "${TB_EDQS_PARTITIONING_STRATEGY:tenant}" + # EDQS requests topic + requests_topic: "${TB_EDQS_REQUESTS_TOPIC:edqs.requests}" + # EDQS responses topic + responses_topic: "${TB_EDQS_RESPONSES_TOPIC:edqs.responses}" + # Poll interval for EDQS topics + poll_interval: "${TB_EDQS_POLL_INTERVAL_MS:125}" + # Maximum amount of pending requests to EDQS + max_pending_requests: "${TB_EDQS_MAX_PENDING_REQUESTS:10000}" + # Maximum timeout for requests to EDQS + max_request_timeout: "${TB_EDQS_MAX_REQUEST_TIMEOUT:20000}" + stats: + # Enable/disable statistics for EDQS + enabled: "${TB_EDQS_STATS_ENABLED:true}" vc: # Default topic name topic: "${TB_QUEUE_VC_TOPIC:tb_version_control}" @@ -1723,6 +1790,8 @@ queue: rule-engine: # Deprecated. It will be removed in the nearest releases topic: "${TB_QUEUE_RULE_ENGINE_TOPIC:tb_rule_engine}" + # For high-priority notifications that require minimum latency and processing time + notifications_topic: "${TB_QUEUE_RULE_ENGINE_NOTIFICATIONS_TOPIC:tb_rule_engine.notifications}" # Interval in milliseconds to poll messages by Rule Engine poll-interval: "${TB_QUEUE_RULE_ENGINE_POLL_INTERVAL_MS:25}" # Timeout for processing a message pack of Rule Engine @@ -1738,6 +1807,21 @@ queue: topic-deletion-delay: "${TB_QUEUE_RULE_ENGINE_TOPIC_DELETION_DELAY_SEC:15}" # Size of the thread pool that handles such operations as partition changes, config updates, queue deletion management-thread-pool-size: "${TB_QUEUE_RULE_ENGINE_MGMT_THREAD_POOL_SIZE:12}" + calculated_fields: + # Topic name for Calculated Field (CF) events from Rule Engine + event_topic: "${TB_QUEUE_CF_EVENT_TOPIC:tb_cf_event}" + # Topic name for Calculated Field (CF) compacted states + state_topic: "${TB_QUEUE_CF_STATE_TOPIC:tb_cf_state}" + # For high-priority notifications that require minimum latency and processing time + notifications_topic: "${TB_QUEUE_CF_NOTIFICATIONS_TOPIC:calculated_field.notifications}" + # Interval in milliseconds to poll messages by CF (Rule Engine) microservices + poll_interval: "${TB_QUEUE_CF_POLL_INTERVAL_MS:1000}" + # Timeout for processing a message pack by CF microservices + pack_processing_timeout: "${TB_QUEUE_CF_PACK_PROCESSING_TIMEOUT_MS:60000}" + # Thread pool size for processing of the incoming messages + pool_size: "${TB_QUEUE_CF_POOL_SIZE:8}" + # RocksDB path for storing CF states + rocks_db_path: "${TB_QUEUE_CF_ROCKS_DB_PATH:${user.home}/.rocksdb/cf_states}" transport: # For high-priority notifications that require minimum latency and processing time notifications_topic: "${TB_QUEUE_TRANSPORT_NOTIFICATIONS_TOPIC:tb_transport.notifications}" @@ -1746,6 +1830,10 @@ queue: edge: # Default topic name topic: "${TB_QUEUE_EDGE_TOPIC:tb_edge}" + # For high-priority notifications that require minimum latency and processing time + notifications_topic: "${TB_QUEUE_EDGE_NOTIFICATIONS_TOPIC:tb_edge.notifications}" + # For edge events messages + event_notifications_topic: "${TB_QUEUE_EDGE_EVENT_NOTIFICATIONS_TOPIC:tb_edge_event.notifications}" # Amount of partitions used by Edge services partitions: "${TB_QUEUE_EDGE_PARTITIONS:10}" # Poll interval for topics related to Edge services diff --git a/application/src/test/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessorTest.java b/application/src/test/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessorTest.java index 00019c07fa..9243e295bc 100644 --- a/application/src/test/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessorTest.java +++ b/application/src/test/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessorTest.java @@ -49,7 +49,7 @@ public class DeviceActorMessageProcessorTest { public void setUp() { systemContext = mock(ActorSystemContext.class); deviceService = mock(DeviceService.class); - willReturn((long)MAX_CONCURRENT_SESSIONS_PER_DEVICE).given(systemContext).getMaxConcurrentSessionsPerDevice(); + willReturn(MAX_CONCURRENT_SESSIONS_PER_DEVICE).given(systemContext).getMaxConcurrentSessionsPerDevice(); willReturn(deviceService).given(systemContext).getDeviceService(); processor = new DeviceActorMessageProcessor(systemContext, tenantId, deviceId); willReturn(mock(TbCoreToTransportService.class)).given(systemContext).getTbCoreToTransportService(); @@ -58,7 +58,7 @@ public class DeviceActorMessageProcessorTest { @Test public void givenSystemContext_whenNewInstance_thenVerifySessionMapMaxSize() { assertThat(processor.sessions, instanceOf(LinkedHashMapRemoveEldest.class)); - assertThat(processor.sessions.getMaxEntries(), is((long)MAX_CONCURRENT_SESSIONS_PER_DEVICE)); + assertThat(processor.sessions.getMaxEntries(), is(MAX_CONCURRENT_SESSIONS_PER_DEVICE)); assertThat(processor.sessions.getRemovalConsumer(), notNullValue()); } diff --git a/application/src/test/java/org/thingsboard/server/actors/service/ComponentActorTest.java b/application/src/test/java/org/thingsboard/server/actors/service/ComponentActorTest.java new file mode 100644 index 0000000000..49bf3c8670 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/actors/service/ComponentActorTest.java @@ -0,0 +1,74 @@ +/** + * 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. + */ +package org.thingsboard.server.actors.service; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.shared.ComponentMsgProcessor; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.msg.TbActorStopReason; + +import java.util.concurrent.ScheduledFuture; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; + +class ComponentActorTest { + ComponentActor componentActor; + + @BeforeEach + void setUp() { + componentActor = Mockito.mock(ComponentActor.class); + } + + @Test + void scheduleStatsPersistTickTest() { + Assertions.assertNull(componentActor.statsScheduledFuture); + ScheduledFuture statsScheduledFuture = Mockito.mock(ScheduledFuture.class); + ActorSystemContext systemContext = Mockito.mock(ActorSystemContext.class); + ReflectionTestUtils.setField(componentActor, "systemContext", systemContext); + ComponentMsgProcessor processor = Mockito.mock(ComponentMsgProcessor.class); + componentActor.processor = processor; + BDDMockito.willReturn(statsScheduledFuture).given(processor).scheduleStatsPersistTick(any(), anyLong()); + BDDMockito.willCallRealMethod().given(componentActor).scheduleStatsPersistTick(); + + componentActor.scheduleStatsPersistTick(); + + Assertions.assertNotNull(componentActor.statsScheduledFuture); + } + + @Test + void destroyTest() { + ScheduledFuture statsScheduledFuture = Mockito.mock(ScheduledFuture.class); + componentActor.statsScheduledFuture = statsScheduledFuture; + Assertions.assertNotNull(componentActor.statsScheduledFuture); + Throwable cause = new Throwable(); + EntityId id = Mockito.mock(EntityId.class); + ReflectionTestUtils.setField(componentActor, "id", id); + BDDMockito.willCallRealMethod().given(componentActor).destroy(any(), any()); + + componentActor.destroy(TbActorStopReason.STOPPED, cause); + + Mockito.verify(statsScheduledFuture).cancel(false); + Assertions.assertNull(componentActor.statsScheduledFuture); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/actors/tenant/TenantActorTest.java b/application/src/test/java/org/thingsboard/server/actors/tenant/TenantActorTest.java index 50349907fa..d8fac2b936 100644 --- a/application/src/test/java/org/thingsboard/server/actors/tenant/TenantActorTest.java +++ b/application/src/test/java/org/thingsboard/server/actors/tenant/TenantActorTest.java @@ -27,6 +27,7 @@ import org.thingsboard.server.actors.TbActorSystemSettings; import org.thingsboard.server.actors.TbEntityActorId; import org.thingsboard.server.actors.ruleChain.RuleChainActor; import org.thingsboard.server.actors.ruleChain.RuleChainToRuleChainMsg; +import org.thingsboard.server.actors.service.DefaultActorService; import org.thingsboard.server.actors.shared.RuleChainErrorActor; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.id.DeviceId; @@ -116,6 +117,7 @@ public class TenantActorTest { TbActorSystemSettings settings = new TbActorSystemSettings(0, 0, 0); TbActorSystem system = spy(new DefaultTbActorSystem(settings)); system.createDispatcher(RULE_DISPATCHER_NAME, mock()); + system.createDispatcher(DefaultActorService.CF_MANAGER_DISPATCHER_NAME, mock()); TbActorMailbox tenantCtx = new TbActorMailbox(system, settings, null, mock(), mock(), null); tenantActor.init(tenantCtx); diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java new file mode 100644 index 0000000000..002f668fdc --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -0,0 +1,480 @@ +/** + * 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. + */ +package org.thingsboard.server.cf; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.debug.DebugSettings; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.controller.CalculatedFieldControllerTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@DaoSqlTest +public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTest { + + public static final int TIMEOUT = 60; + public static final int POLL_INTERVAL = 1; + + @BeforeEach + void setUp() throws Exception { + loginTenantAdmin(); + } + + @Test + public void testSimpleCalculatedFieldWhenAllTelemetryPresent() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":25}")); + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"deviceTemperature\":40}")); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("C to F"); + calculatedField.setDebugSettings(DebugSettings.all()); + calculatedField.setConfigurationVersion(1); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); + argument.setRefEntityKey(refEntityKey); + argument.setDefaultValue("12"); // not used because real telemetry value in db is present + config.setArguments(Map.of("T", argument)); + config.setExpression("(T * 9/5) + 32"); + + Output output = new Output(); + output.setName("fahrenheitTemp"); + output.setType(OutputType.TIME_SERIES); + config.setOutput(output); + + calculatedField.setConfiguration(config); + calculatedField.setVersion(1L); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "fahrenheitTemp"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0"); + }); + + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":30}")); + + await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "fahrenheitTemp"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("86.0"); + }); + + Output savedOutput = savedCalculatedField.getConfiguration().getOutput(); + savedOutput.setType(OutputType.ATTRIBUTES); + savedOutput.setScope(AttributeScope.SERVER_SCOPE); + savedOutput.setName("temperatureF"); + savedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); + + await().alias("update CF output -> perform calculation with updated output").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode temperatureF = getServerAttributes(testDevice.getId(), "temperatureF"); + assertThat(temperatureF).isNotNull(); + assertThat(temperatureF.get(0).get("value").asText()).isEqualTo("86.0"); + }); + + Argument savedArgument = savedCalculatedField.getConfiguration().getArguments().get("T"); + savedArgument.setRefEntityKey(new ReferencedEntityKey("deviceTemperature", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + savedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); + + await().alias("update CF argument -> perform calculation with new argument").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode temperatureF = getServerAttributes(testDevice.getId(), "temperatureF"); + assertThat(temperatureF).isNotNull(); + assertThat(temperatureF.get(0).get("value").asText()).isEqualTo("104.0"); + }); + + savedCalculatedField.getConfiguration().setExpression("1.8 * T + 32"); + savedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); + + await().alias("update CF expression -> perform calculation with new expression").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode temperatureF = getServerAttributes(testDevice.getId(), "temperatureF"); + assertThat(temperatureF).isNotNull(); + assertThat(temperatureF.get(0).get("value").asText()).isEqualTo("104.0"); + }); + } + + @Test + public void testSimpleCalculatedFieldWhenNotAllTelemetryPresent() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("C to F"); + calculatedField.setDebugSettings(DebugSettings.all()); + calculatedField.setConfigurationVersion(1); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); + argument.setRefEntityKey(refEntityKey); + config.setArguments(Map.of("T", argument)); + config.setExpression("(T * 9/5) + 32"); + + Output output = new Output(); + output.setName("fahrenheitTemp"); + output.setType(OutputType.TIME_SERIES); + config.setOutput(output); + + calculatedField.setConfiguration(config); + calculatedField.setVersion(1L); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + await().alias("create CF -> state is not ready -> no calculation performed").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "fahrenheitTemp"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").isNull()).isTrue(); + }); + + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":30}")); + + await().alias("update telemetry -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "fahrenheitTemp"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("86.0"); + }); + } + + @Test + public void testSimpleCalculatedFieldWhenNotAllTelemetryPresentButDefaultValueIsSet() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("C to F"); + calculatedField.setDebugSettings(DebugSettings.all()); + calculatedField.setConfigurationVersion(1); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); + argument.setRefEntityKey(refEntityKey); + argument.setDefaultValue("12"); + config.setArguments(Map.of("T", argument)); + config.setExpression("(T * 9/5) + 32"); + + Output output = new Output(); + output.setName("fahrenheitTemp"); + output.setType(OutputType.TIME_SERIES); + config.setOutput(output); + + calculatedField.setConfiguration(config); + calculatedField.setVersion(1L); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + await().alias("create CF -> perform initial calculation with default value").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "fahrenheitTemp"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("53.6"); + }); + + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":30}")); + + await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "fahrenheitTemp"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("86.0"); + }); + } + + @Test + public void testSimpleCalculatedFieldWhenEntityIdIsProfile() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"x\":40}")); + + AssetProfile assetProfile = doPost("/api/assetProfile", createAssetProfile("Test Asset Profile"), AssetProfile.class); + + Asset asset1 = createAsset("Test asset 1", assetProfile.getId()); + doPost("/api/plugins/telemetry/ASSET/" + asset1.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":11}")); + + Asset asset2 = createAsset("Test asset 2", assetProfile.getId()); + doPost("/api/plugins/telemetry/ASSET/" + asset2.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":12}")); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(assetProfile.getId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("z = x + y"); + calculatedField.setDebugSettings(DebugSettings.all()); + calculatedField.setConfigurationVersion(1); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument1 = new Argument(); + ReferencedEntityKey refEntityKey1 = new ReferencedEntityKey("y", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE); + argument1.setRefEntityKey(refEntityKey1); + + Argument argument2 = new Argument(); + argument2.setRefEntityId(testDevice.getId()); + ReferencedEntityKey refEntityKey2 = new ReferencedEntityKey("x", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE); + argument2.setRefEntityKey(refEntityKey2); + + config.setArguments(Map.of("x", argument2, "y", argument1)); + + config.setExpression("x + y"); + + Output output = new Output(); + output.setName("z"); + output.setType(OutputType.ATTRIBUTES); + output.setScope(AttributeScope.SERVER_SCOPE); + + config.setOutput(output); + + calculatedField.setConfiguration(config); + calculatedField.setVersion(1L); + + doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + await().alias("create CF and perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + // result of asset 1 + ArrayNode z1 = getServerAttributes(asset1.getId(), "z"); + assertThat(z1).isNotNull(); + assertThat(z1.get(0).get("value").asText()).isEqualTo("51.0"); + + // result of asset 2 + ArrayNode z2 = getServerAttributes(asset2.getId(), "z"); + assertThat(z2).isNotNull(); + assertThat(z2.get(0).get("value").asText()).isEqualTo("52.0"); + }); + + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"x\":25}")); + + await().alias("update device telemetry -> recalculate state for all assets").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + // result of asset 1 + ArrayNode z1 = getServerAttributes(asset1.getId(), "z"); + assertThat(z1).isNotNull(); + assertThat(z1.get(0).get("value").asText()).isEqualTo("36.0"); + + // result of asset 2 + ArrayNode z2 = getServerAttributes(asset2.getId(), "z"); + assertThat(z2).isNotNull(); + assertThat(z2.get(0).get("value").asText()).isEqualTo("37.0"); + }); + + doPost("/api/plugins/telemetry/ASSET/" + asset1.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":15}")); + + await().alias("update asset 1 telemetry -> recalculate state only for asset 1").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + // result of asset 1 + ArrayNode z1 = getServerAttributes(asset1.getId(), "z"); + assertThat(z1).isNotNull(); + assertThat(z1.get(0).get("value").asText()).isEqualTo("40.0"); + + // result of asset 2 (no changes) + ArrayNode z2 = getServerAttributes(asset2.getId(), "z"); + assertThat(z2).isNotNull(); + assertThat(z2.get(0).get("value").asText()).isEqualTo("37.0"); + }); + + doPost("/api/plugins/telemetry/ASSET/" + asset2.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":5}")); + + await().alias("update asset 2 telemetry -> recalculate state only for asset 2").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + // result of asset 1 (no changes) + ArrayNode z1 = getServerAttributes(asset1.getId(), "z"); + assertThat(z1).isNotNull(); + assertThat(z1.get(0).get("value").asText()).isEqualTo("40.0"); + + // result of asset 2 + ArrayNode z2 = getServerAttributes(asset2.getId(), "z"); + assertThat(z2).isNotNull(); + assertThat(z2.get(0).get("value").asText()).isEqualTo("30.0"); + }); + + Asset asset3 = createAsset("Test asset 3", assetProfile.getId()); + doPost("/api/plugins/telemetry/ASSET/" + asset3.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":13}")); + + Asset finalAsset3 = asset3; + await().alias("add new entity to profile -> calculate state for new entity").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + // result of asset 3 + ArrayNode z3 = getServerAttributes(finalAsset3.getId(), "z"); + assertThat(z3).isNotNull(); + assertThat(z3.get(0).get("value").asText()).isEqualTo("38.0"); + }); + + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"x\":20}")); + + await().alias("update device telemetry -> recalculate state for all assets").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + // result of asset 1 + ArrayNode z1 = getServerAttributes(asset1.getId(), "z"); + assertThat(z1).isNotNull(); + assertThat(z1.get(0).get("value").asText()).isEqualTo("35.0"); + + // result of asset 2 + ArrayNode z2 = getServerAttributes(asset2.getId(), "z"); + assertThat(z2).isNotNull(); + assertThat(z2.get(0).get("value").asText()).isEqualTo("25.0"); + + // result of asset 3 + ArrayNode z3 = getServerAttributes(finalAsset3.getId(), "z"); + assertThat(z3).isNotNull(); + assertThat(z3.get(0).get("value").asText()).isEqualTo("33.0"); + }); + + // update profile for asset 3 -> delete state for asset 3 + AssetProfile newAssetProfile = doPost("/api/assetProfile", createAssetProfile("New Asset Profile"), AssetProfile.class); + asset3.setAssetProfileId(newAssetProfile.getId()); + asset3 = doPost("/api/asset", asset3, Asset.class); + + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"x\":15}")); + + Asset updatedAsset3 = asset3; + await().alias("update device telemetry -> recalculate state for asset 1 and asset 2").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + // result of asset 1 + ArrayNode z1 = getServerAttributes(asset1.getId(), "z"); + assertThat(z1).isNotNull(); + assertThat(z1.get(0).get("value").asText()).isEqualTo("30.0"); + + // result of asset 2 + ArrayNode z2 = getServerAttributes(asset2.getId(), "z"); + assertThat(z2).isNotNull(); + assertThat(z2.get(0).get("value").asText()).isEqualTo("20.0"); + + // no changes for asset 3 + ArrayNode z3 = getServerAttributes(updatedAsset3.getId(), "z"); + assertThat(z3).isNotNull(); + assertThat(z3.get(0).get("value").asText()).isEqualTo("33.0"); + }); + } + + @Test + public void testSimpleCalculatedFieldWhenExpressionIsInvalid() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":25}")); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("C to F"); + calculatedField.setDebugSettings(DebugSettings.all()); + calculatedField.setConfigurationVersion(1); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); + argument.setRefEntityKey(refEntityKey); + argument.setDefaultValue("12"); // not used because real telemetry value in db is present + config.setArguments(Map.of("T", argument)); + config.setExpression("(T * 9/0) + 32"); + + Output output = new Output(); + output.setName("fahrenheitTemp"); + output.setType(OutputType.TIME_SERIES); + config.setOutput(output); + + calculatedField.setConfiguration(config); + calculatedField.setVersion(1L); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + await().alias("create CF -> ctx is not initialized -> no calculation perform").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "fahrenheitTemp"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").isNull()).isTrue(); + }); + + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":30}")); + + await().alias("update telemetry -> ctx is not initialized -> no calculation perform").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "fahrenheitTemp"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").isNull()).isTrue(); + }); + } + + private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { + return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); + } + + private ArrayNode getServerAttributes(EntityId entityId, String... keys) throws Exception { + return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/attributes/SERVER_SCOPE?keys=" + String.join(",", keys), ArrayNode.class); + } + + private Asset createAsset(String name, AssetProfileId assetProfileId) { + Asset asset = new Asset(); + asset.setName(name); + asset.setAssetProfileId(assetProfileId); + return doPost("/api/asset", asset, Asset.class); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index 975c3b426b..33f2209eca 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -38,6 +38,7 @@ import org.mockito.Mockito; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; @@ -143,6 +144,7 @@ import org.thingsboard.server.dao.device.ClaimDevicesService; import org.thingsboard.server.dao.tenant.TenantProfileService; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.queue.memory.InMemoryStorage; +import org.thingsboard.server.service.cf.CfRocksDb; import org.thingsboard.server.service.entitiy.tenant.profile.TbTenantProfileService; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRequest; import org.thingsboard.server.service.security.auth.rest.LoginRequest; @@ -276,6 +278,9 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { @Autowired protected InMemoryStorage storage; + @MockBean + protected CfRocksDb cfRocksDb; + @Rule public TestRule watcher = new TestWatcher() { protected void starting(Description description) { @@ -402,7 +407,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { } catch (Exception e) { throw new RuntimeException(e); } - Awaitility.await("all tasks processed").atMost(60, TimeUnit.SECONDS).during(300, TimeUnit.MILLISECONDS) + Awaitility.await("all tasks processed").atMost(90, TimeUnit.SECONDS).during(300, TimeUnit.MILLISECONDS) .until(() -> storage.getLag("tb_housekeeper") == 0); } diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java index 77002cef8e..b0a3518e60 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java @@ -28,6 +28,7 @@ import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.TestPropertySource; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -73,6 +74,7 @@ import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.argThat; @@ -325,6 +327,59 @@ public class BaseQueueControllerTest extends AbstractControllerTest { doDelete("/api/queues/" + queue.getUuidId()).andExpect(status().isOk()); } + @Test + public void testQueueWithReservedName() throws Exception { + loginSysAdmin(); + + // create queue + Queue queue = new Queue(); + queue.setName(DataConstants.CF_QUEUE_NAME); + queue.setTopic("tb_rule_engine.calculated_fields"); + queue.setPollInterval(25); + queue.setPartitions(10); + queue.setTenantId(TenantId.SYS_TENANT_ID); + queue.setConsumerPerPartition(false); + queue.setPackProcessingTimeout(2000); + SubmitStrategy submitStrategy = new SubmitStrategy(); + submitStrategy.setType(SubmitStrategyType.SEQUENTIAL_BY_ORIGINATOR); + queue.setSubmitStrategy(submitStrategy); + ProcessingStrategy processingStrategy = new ProcessingStrategy(); + processingStrategy.setType(ProcessingStrategyType.RETRY_ALL); + processingStrategy.setRetries(3); + processingStrategy.setFailurePercentage(0.7); + processingStrategy.setPauseBetweenRetries(3); + processingStrategy.setMaxPauseBetweenRetries(5); + queue.setProcessingStrategy(processingStrategy); + + doPost("/api/queues?serviceType=" + "TB-RULE-ENGINE", queue) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(String.format("The queue name '%s' is not allowed. This name is reserved for internal use. Please choose a different name.", DataConstants.CF_QUEUE_NAME)))); + + // create queue + Queue queue2 = new Queue(); + queue2.setName(DataConstants.CF_STATES_QUEUE_NAME); + queue2.setTopic("tb_rule_engine.calculated_fields"); + queue2.setPollInterval(25); + queue2.setPartitions(10); + queue2.setTenantId(TenantId.SYS_TENANT_ID); + queue2.setConsumerPerPartition(false); + queue2.setPackProcessingTimeout(2000); + SubmitStrategy submitStrategy2 = new SubmitStrategy(); + submitStrategy2.setType(SubmitStrategyType.SEQUENTIAL_BY_ORIGINATOR); + queue2.setSubmitStrategy(submitStrategy); + ProcessingStrategy processingStrategy2 = new ProcessingStrategy(); + processingStrategy2.setType(ProcessingStrategyType.RETRY_ALL); + processingStrategy2.setRetries(3); + processingStrategy2.setFailurePercentage(0.7); + processingStrategy2.setPauseBetweenRetries(3); + processingStrategy2.setMaxPauseBetweenRetries(5); + queue2.setProcessingStrategy(processingStrategy); + + doPost("/api/queues?serviceType=" + "TB-RULE-ENGINE", queue2) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(String.format("The queue name '%s' is not allowed. This name is reserved for internal use. Please choose a different name.", DataConstants.CF_STATES_QUEUE_NAME)))); + } + private Queue saveQueue(Queue queue) { return doPost("/api/queues?serviceType=TB_RULE_ENGINE", queue, Queue.class); } diff --git a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java new file mode 100644 index 0000000000..ee66f664cc --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java @@ -0,0 +1,163 @@ +/** + * 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. + */ +package org.thingsboard.server.controller; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +public class CalculatedFieldControllerTest extends AbstractControllerTest { + + private Tenant savedTenant; + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = saveTenant(tenant); + assertThat(savedTenant).isNotNull(); + + User tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + + createUserAndLogin(tenantAdmin, "testPassword1"); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + deleteTenant(savedTenant.getId()); + } + + @Test + public void testSaveCalculatedField() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getCalculatedField(testDevice.getId()); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + assertThat(savedCalculatedField).isNotNull(); + assertThat(savedCalculatedField.getId()).isNotNull(); + assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0); + assertThat(savedCalculatedField.getTenantId()).isEqualTo(savedTenant.getId()); + assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId()); + assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); + assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); + assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getCalculatedFieldConfig(testDevice.getId())); + assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); + + savedCalculatedField.setName("Test CF"); + + CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); + + assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName()); + assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1); + + doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testGetCalculatedFieldById() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getCalculatedField(testDevice.getId()); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + CalculatedField fetchedCalculatedField = doGet("/api/calculatedField/" + savedCalculatedField.getId().getId(), CalculatedField.class); + + assertThat(fetchedCalculatedField).isNotNull(); + assertThat(fetchedCalculatedField).isEqualTo(savedCalculatedField); + + doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testDeleteCalculatedField() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getCalculatedField(testDevice.getId()); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + assertThat(savedCalculatedField).isNotNull(); + + doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString()) + .andExpect(status().isOk()); + doGet("/api/calculatedField/" + savedCalculatedField.getId().getId()).andExpect(status().isNotFound()); + } + + private CalculatedField getCalculatedField(DeviceId deviceId) { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(deviceId); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("Test Calculated Field"); + calculatedField.setConfigurationVersion(1); + calculatedField.setConfiguration(getCalculatedFieldConfig(null)); + calculatedField.setVersion(1L); + return calculatedField; + } + + private CalculatedFieldConfiguration getCalculatedFieldConfig(EntityId referencedEntityId) { + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + argument.setRefEntityId(referencedEntityId); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); + argument.setRefEntityKey(refEntityKey); + + config.setArguments(Map.of("T", argument)); + + config.setExpression("T - (100 - H) / 5"); + + Output output = new Output(); + output.setName("output"); + output.setType(OutputType.TIME_SERIES); + + config.setOutput(output); + + return config; + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java new file mode 100644 index 0000000000..153ec2d26f --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java @@ -0,0 +1,72 @@ +/** + * 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. + */ +package org.thingsboard.server.controller; + +import org.junit.Before; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.msg.edqs.EdqsApiService; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.edqs.state.EdqsStateService; +import org.thingsboard.server.edqs.util.EdqsRocksDb; + +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; + +@DaoSqlTest +@TestPropertySource(properties = { +// "queue.type=kafka", // uncomment to use Kafka +// "queue.kafka.bootstrap.servers=10.7.2.107:9092", + "queue.edqs.sync.enabled=true", + "queue.edqs.api.supported=true", + "queue.edqs.api.auto_enable=true", + "queue.edqs.mode=local" +}) +public class EdqsEntityQueryControllerTest extends EntityQueryControllerTest { + + @Autowired + private EdqsApiService edqsApiService; + + @Autowired + private EdqsStateService edqsStateService; + + @MockBean // so that we don't do backup for tests + private EdqsRocksDb edqsRocksDb; + + @Before + public void before() { + await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> edqsApiService.isEnabled() && edqsStateService.isReady()); + } + + @Override + protected PageData findByQueryAndCheck(EntityDataQuery query, int expectedResultSize) { + return await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> findByQuery(query), + result -> result.getTotalElements() == expectedResultSize); + } + + @Override + protected Long countByQueryAndCheck(EntityCountQuery query, long expectedResult) { + return await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> countByQuery(query), + result -> result == expectedResult); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java index ac618d7f96..011399e883 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -18,12 +18,14 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import org.awaitility.Awaitility; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.web.servlet.ResultActions; +import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Dashboard; @@ -49,6 +51,7 @@ import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityDataSortOrder; import org.thingsboard.server.common.data.query.EntityKey; import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityKeyValueType; import org.thingsboard.server.common.data.query.EntityListFilter; import org.thingsboard.server.common.data.query.EntityTypeFilter; import org.thingsboard.server.common.data.query.FilterPredicateValue; @@ -70,9 +73,11 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; +import java.util.function.BiPredicate; import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @DaoSqlTest @@ -130,36 +135,25 @@ public class EntityQueryControllerTest extends AbstractControllerTest { filter.setDeviceNameFilter(""); EntityCountQuery countQuery = new EntityCountQuery(filter); - - Long count = doPostWithResponse("/api/entitiesQuery/count", countQuery, Long.class); - Assert.assertEquals(97, count.longValue()); + countByQueryAndCheck(countQuery, 97); filter.setDeviceTypes(List.of("unknown")); - count = doPostWithResponse("/api/entitiesQuery/count", countQuery, Long.class); - Assert.assertEquals(0, count.longValue()); + countByQueryAndCheck(countQuery, 0); filter.setDeviceTypes(List.of("default")); filter.setDeviceNameFilter("Device1"); - - count = doPostWithResponse("/api/entitiesQuery/count", countQuery, Long.class); - Assert.assertEquals(11, count.longValue()); + countByQueryAndCheck(countQuery, 11); EntityListFilter entityListFilter = new EntityListFilter(); entityListFilter.setEntityType(EntityType.DEVICE); entityListFilter.setEntityList(devices.stream().map(Device::getId).map(DeviceId::toString).collect(Collectors.toList())); - countQuery = new EntityCountQuery(entityListFilter); - - count = doPostWithResponse("/api/entitiesQuery/count", countQuery, Long.class); - Assert.assertEquals(97, count.longValue()); + countByQueryAndCheck(countQuery, 97); EntityTypeFilter filter2 = new EntityTypeFilter(); filter2.setEntityType(EntityType.DEVICE); - - EntityCountQuery countQuery2 = new EntityCountQuery(filter2); - - Long count2 = doPostWithResponse("/api/entitiesQuery/count", countQuery2, Long.class); - Assert.assertEquals(97, count2.longValue()); + countQuery = new EntityCountQuery(filter2); + countByQueryAndCheck(countQuery, 97); } @Test @@ -169,51 +163,44 @@ public class EntityQueryControllerTest extends AbstractControllerTest { EntityTypeFilter allDeviceFilter = new EntityTypeFilter(); allDeviceFilter.setEntityType(EntityType.DEVICE); EntityCountQuery query = new EntityCountQuery(allDeviceFilter); - Long initialCount = doPostWithResponse("/api/entitiesQuery/count", query, Long.class); + countByQueryAndCheck(query, 0); loginTenantAdmin(); List devices = new ArrayList<>(); + String devicePrefix = "Device" + RandomStringUtils.randomAlphabetic(5); for (int i = 0; i < 97; i++) { Device device = new Device(); - device.setName("Device" + i); + device.setName(devicePrefix + i); device.setType("default"); device.setLabel("testLabel" + (int) (Math.random() * 1000)); devices.add(doPost("/api/device", device, Device.class)); Thread.sleep(1); } DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceType("default"); + filter.setDeviceTypes(List.of("default")); filter.setDeviceNameFilter(""); loginSysAdmin(); EntityCountQuery countQuery = new EntityCountQuery(filter); + countByQueryAndCheck(countQuery, 97); - Long count = doPostWithResponse("/api/entitiesQuery/count", countQuery, Long.class); - Assert.assertEquals(97, count.longValue()); - - filter.setDeviceType("unknown"); - count = doPostWithResponse("/api/entitiesQuery/count", countQuery, Long.class); - Assert.assertEquals(0, count.longValue()); - - filter.setDeviceType("default"); - filter.setDeviceNameFilter("Device1"); + filter.setDeviceTypes(List.of("unknown")); + countByQueryAndCheck(countQuery, 0); - count = doPostWithResponse("/api/entitiesQuery/count", countQuery, Long.class); - Assert.assertEquals(11, count.longValue()); + filter.setDeviceTypes(List.of("default")); + filter.setDeviceNameFilter(devicePrefix + "1"); + countByQueryAndCheck(countQuery, 11); EntityListFilter entityListFilter = new EntityListFilter(); entityListFilter.setEntityType(EntityType.DEVICE); entityListFilter.setEntityList(devices.stream().map(Device::getId).map(DeviceId::toString).collect(Collectors.toList())); countQuery = new EntityCountQuery(entityListFilter); + countByQueryAndCheck(countQuery, 97); - count = doPostWithResponse("/api/entitiesQuery/count", countQuery, Long.class); - Assert.assertEquals(97, count.longValue()); - - Long count2 = doPostWithResponse("/api/entitiesQuery/count", query, Long.class); - Assert.assertEquals(initialCount + 97, count2.longValue()); + countByQueryAndCheck(countQuery, 97); } @Test @@ -371,11 +358,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, null, null); - PageData data = - doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() { - }); - - Assert.assertEquals(97, data.getTotalElements()); + PageData data = findByQueryAndCheck(query, 97); Assert.assertEquals(10, data.getTotalPages()); Assert.assertTrue(data.hasNext()); Assert.assertEquals(10, data.getData().size()); @@ -383,8 +366,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { List loadedEntities = new ArrayList<>(data.getData()); while (data.hasNext()) { query = query.next(); - data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() { - }); + data = findByQuery(query); loadedEntities.addAll(data.getData()); } Assert.assertEquals(97, loadedEntities.size()); @@ -406,8 +388,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { pageLink = new EntityDataPageLink(10, 0, "device1", sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, null, null); - data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() { - }); + data = findByQuery(query); Assert.assertEquals(11, data.getTotalElements()); Assert.assertEquals("Device19", data.getData().get(0).getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()); @@ -423,9 +404,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { EntityDataQuery query2 = new EntityDataQuery(filter2, pageLink2, entityFields2, null, null); - PageData data2 = - doPostWithTypedResponse("/api/entitiesQuery/find", query2, new TypeReference>() { - }); + PageData data2 = findByQuery(query2); Assert.assertEquals(97, data2.getTotalElements()); Assert.assertEquals(10, data2.getTotalPages()); @@ -473,20 +452,15 @@ public class EntityQueryControllerTest extends AbstractControllerTest { List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, null, null); - - PageData data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference<>() {}); - - Assert.assertEquals(87, data.getTotalElements()); + findByQueryAndCheck(query, 87); filter.setFilters(List.of(new RelationEntityTypeFilter("NOT_CONTAINS", List.of(EntityType.DEVICE), false))); query = new EntityDataQuery(filter, pageLink, entityFields, null, null); - data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference<>() {}); - Assert.assertEquals(10, data.getTotalElements()); + findByQueryAndCheck(query, 10); filter.setFilters(List.of(new RelationEntityTypeFilter("NOT_CONTAINS", List.of(EntityType.DEVICE), true))); query = new EntityDataQuery(filter, pageLink, entityFields, null, null); - data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference<>() {}); - Assert.assertEquals(87, data.getTotalElements()); + findByQueryAndCheck(query, 87); } private EntityRelation createFromRelation(Device mainDevice, Device device, String relationType) { @@ -531,14 +505,12 @@ public class EntityQueryControllerTest extends AbstractControllerTest { List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, null); - PageData data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() { - }); + PageData data = findByQueryAndCheck(query, 67); List loadedEntities = new ArrayList<>(data.getData()); while (data.hasNext()) { query = query.next(); - data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() { - }); + data = findByQuery(query); loadedEntities.addAll(data.getData()); } Assert.assertEquals(67, loadedEntities.size()); @@ -551,6 +523,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { pageLink = new EntityDataPageLink(10, 0, null, sortOrder); KeyFilter highTemperatureFilter = new KeyFilter(); highTemperatureFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + highTemperatureFilter.setValueType(EntityKeyValueType.NUMERIC); NumericFilterPredicate predicate = new NumericFilterPredicate(); predicate.setValue(FilterPredicateValue.fromDouble(45)); predicate.setOperation(NumericFilterPredicate.NumericOperation.GREATER); @@ -559,13 +532,11 @@ public class EntityQueryControllerTest extends AbstractControllerTest { query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); - data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() { - }); + data = findByQuery(query); loadedEntities = new ArrayList<>(data.getData()); while (data.hasNext()) { query = query.next(); - data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() { - }); + data = findByQuery(query); loadedEntities.addAll(data.getData()); } Assert.assertEquals(highTemperatures.size(), loadedEntities.size()); @@ -604,6 +575,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { KeyFilter highTemperatureFilter = new KeyFilter(); highTemperatureFilter.setKey(new EntityKey(EntityKeyType.SERVER_ATTRIBUTE, "alarmActiveTime")); + highTemperatureFilter.setValueType(EntityKeyValueType.NUMERIC); NumericFilterPredicate predicate = new NumericFilterPredicate(); DynamicValue dynamicValue = @@ -627,16 +599,16 @@ public class EntityQueryControllerTest extends AbstractControllerTest { EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); - Awaitility.await() + await() .alias("data by query") .atMost(TIMEOUT, TimeUnit.SECONDS) .until(() -> { - var data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() {}); + var data = findByQuery(query); var loadedEntities = new ArrayList<>(data.getData()); return loadedEntities.size() == numOfDevices; }); - var data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() {}); + var data = findByQuery(query); var loadedEntities = new ArrayList<>(data.getData()); Assert.assertEquals(numOfDevices, loadedEntities.size()); @@ -694,11 +666,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { EntityDataQuery query = new EntityDataQuery(entityTypeFilter, pageLink, entityFields, null, null); - PageData data = - doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() { - }); - - Assert.assertEquals(97, data.getTotalElements()); + PageData data = findByQueryAndCheck(query, 97); Assert.assertEquals(10, data.getTotalPages()); Assert.assertTrue(data.hasNext()); Assert.assertEquals(10, data.getData().size()); @@ -712,9 +680,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { }); EntityCountQuery countQuery = new EntityCountQuery(entityTypeFilter); - - Long count = doPostWithResponse("/api/entitiesQuery/count", countQuery, Long.class); - Assert.assertEquals(97, count.longValue()); + countByQueryAndCheck(countQuery, 97); } @Test @@ -742,28 +708,29 @@ public class EntityQueryControllerTest extends AbstractControllerTest { KeyFilter activeAlarmTimeToLongFilter = getServerAttributeNumericGreaterThanKeyFilter("alarmActiveTime", 30); KeyFilter tenantOwnerNameFilter = getEntityFieldStringEqualToKeyFilter("ownerName", TEST_TENANT_NAME); KeyFilter wrongOwnerNameFilter = getEntityFieldStringEqualToKeyFilter("ownerName", "wrongName"); - KeyFilter tenantOwnerTypeFilter = getEntityFieldStringEqualToKeyFilter("ownerType", "TENANT"); + KeyFilter tenantOwnerTypeFilter = getEntityFieldStringEqualToKeyFilter("ownerType", "TENANT"); KeyFilter customerOwnerTypeFilter = getEntityFieldStringEqualToKeyFilter("ownerType", "CUSTOMER"); // all devices with ownerName = TEST TENANT - EntityCountQuery query = new EntityCountQuery(filter, List.of(activeAlarmTimeFilter, tenantOwnerNameFilter)); - checkEntitiesCount(query, numOfDevices); + EntityCountQuery query = new EntityCountQuery(filter, List.of(activeAlarmTimeFilter, tenantOwnerNameFilter)); + await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> countByQuery(query), + result -> result == numOfDevices); // all devices with ownerName = TEST TENANT - EntityCountQuery activeAlarmTimeToLongQuery = new EntityCountQuery(filter, List.of(activeAlarmTimeToLongFilter, tenantOwnerNameFilter)); - checkEntitiesCount(activeAlarmTimeToLongQuery, 0); + EntityCountQuery activeAlarmTimeToLongQuery = new EntityCountQuery(filter, List.of(activeAlarmTimeToLongFilter, tenantOwnerNameFilter)); + countByQueryAndCheck(activeAlarmTimeToLongQuery, 0); // all devices with wrong ownerName EntityCountQuery wrongTenantNameQuery = new EntityCountQuery(filter, List.of(activeAlarmTimeFilter, wrongOwnerNameFilter)); - checkEntitiesCount(wrongTenantNameQuery, 0); + countByQueryAndCheck(wrongTenantNameQuery, 0); // all devices with owner type = TENANT EntityCountQuery tenantEntitiesQuery = new EntityCountQuery(filter, List.of(activeAlarmTimeFilter, tenantOwnerTypeFilter)); - checkEntitiesCount(tenantEntitiesQuery, numOfDevices); + countByQueryAndCheck(tenantEntitiesQuery, numOfDevices); // all devices with owner type = CUSTOMER EntityCountQuery customerEntitiesQuery = new EntityCountQuery(filter, List.of(activeAlarmTimeFilter, customerOwnerTypeFilter)); - checkEntitiesCount(customerEntitiesQuery, 0); + countByQueryAndCheck(customerEntitiesQuery, 0); } @Test @@ -790,7 +757,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { KeyFilter activeAlarmTimeFilter = getServerAttributeNumericGreaterThanKeyFilter("alarmActiveTime", 5); KeyFilter tenantOwnerNameFilter = getEntityFieldStringEqualToKeyFilter("ownerName", TEST_TENANT_NAME); KeyFilter wrongOwnerNameFilter = getEntityFieldStringEqualToKeyFilter("ownerName", "wrongName"); - KeyFilter tenantOwnerTypeFilter = getEntityFieldStringEqualToKeyFilter("ownerType", "TENANT"); + KeyFilter tenantOwnerTypeFilter = getEntityFieldStringEqualToKeyFilter("ownerType", "TENANT"); KeyFilter customerOwnerTypeFilter = getEntityFieldStringEqualToKeyFilter("ownerType", "CUSTOMER"); EntityDataSortOrder sortOrder = new EntityDataSortOrder( @@ -851,41 +818,29 @@ public class EntityQueryControllerTest extends AbstractControllerTest { EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, null, null); - PageData data = - doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() { - }); - - Assert.assertEquals(1, data.getTotalElements()); - Assert.assertEquals(1, data.getTotalPages()); - Assert.assertEquals(1, data.getData().size()); + findByQueryAndCheck(query, 1); // unnassign dashboard login(TENANT_EMAIL, TENANT_PASSWORD); doDelete("/api/customer/" + savedCustomer.getId().getId().toString() + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); login(CUSTOMER_USER_EMAIL, CUSTOMER_USER_PASSWORD); - PageData dataAfterUnassign = - doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() { - }); - - Assert.assertEquals(0, dataAfterUnassign.getTotalElements()); - Assert.assertEquals(0, dataAfterUnassign.getTotalPages()); - Assert.assertEquals(0, dataAfterUnassign.getData().size()); + findByQueryAndCheck(query, 0); } private void checkEntitiesByQuery(EntityDataQuery query, int expectedNumOfDevices, String expectedOwnerName, String expectedOwnerType) throws Exception { - Awaitility.await() + await() .alias("data by query") .atMost(30, TimeUnit.SECONDS) .until(() -> { - var data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() {}); + var data = findByQuery(query); var loadedEntities = new ArrayList<>(data.getData()); return loadedEntities.size() == expectedNumOfDevices; }); - if (expectedNumOfDevices == 0) { - return; - } - var data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() {}); + if (expectedNumOfDevices == 0) { + return; + } + var data = findByQuery(query); var loadedEntities = new ArrayList<>(data.getData()); Assert.assertEquals(expectedNumOfDevices, loadedEntities.size()); @@ -898,25 +853,37 @@ public class EntityQueryControllerTest extends AbstractControllerTest { String alarmActiveTime = entity.getLatest().get(EntityKeyType.ATTRIBUTE).getOrDefault("alarmActiveTime", new TsValue(0, "-1")).getValue(); Assert.assertEquals("Device" + i, name); - Assert.assertEquals( expectedOwnerName, ownerName); - Assert.assertEquals( expectedOwnerType, ownerType); + Assert.assertEquals(expectedOwnerName, ownerName); + Assert.assertEquals(expectedOwnerType, ownerType); Assert.assertEquals("1" + i, alarmActiveTime); } } - private void checkEntitiesCount(EntityCountQuery query, int expectedNumOfDevices) { - Awaitility.await() - .alias("count by query") - .atMost(30, TimeUnit.SECONDS) - .until(() -> { - var count = doPost("/api/entitiesQuery/count", query, Integer.class); - return count == expectedNumOfDevices; - }); - } + protected PageData findByQuery(EntityDataQuery query) throws Exception { + return doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference<>() { + }); + } + + protected PageData findByQueryAndCheck(EntityDataQuery query, int expectedResultSize) throws Exception { + PageData result = findByQuery(query); + assertThat(result.getTotalElements()).isEqualTo(expectedResultSize); + return result; + } + + protected Long countByQuery(EntityCountQuery countQuery) throws Exception { + return doPostWithResponse("/api/entitiesQuery/count", countQuery, Long.class); + } + + protected Long countByQueryAndCheck(EntityCountQuery query, long expectedResult) throws Exception { + Long result = countByQuery(query); + assertThat(result).isEqualTo(expectedResult); + return result; + } private KeyFilter getEntityFieldStringEqualToKeyFilter(String keyName, String value) { KeyFilter tenantOwnerNameFilter = new KeyFilter(); tenantOwnerNameFilter.setKey(new EntityKey(EntityKeyType.ENTITY_FIELD, keyName)); + tenantOwnerNameFilter.setValueType(EntityKeyValueType.STRING); StringFilterPredicate ownerNamePredicate = new StringFilterPredicate(); ownerNamePredicate.setValue(FilterPredicateValue.fromString(value)); ownerNamePredicate.setOperation(StringFilterPredicate.StringOperation.EQUAL); @@ -927,6 +894,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { private KeyFilter getServerAttributeNumericGreaterThanKeyFilter(String attribute, int value) { KeyFilter numericFilter = new KeyFilter(); numericFilter.setKey(new EntityKey(EntityKeyType.SERVER_ATTRIBUTE, attribute)); + numericFilter.setValueType(EntityKeyValueType.NUMERIC); NumericFilterPredicate predicate = new NumericFilterPredicate(); predicate.setValue(FilterPredicateValue.fromDouble(value)); predicate.setOperation(NumericFilterPredicate.NumericOperation.GREATER); diff --git a/application/src/test/java/org/thingsboard/server/controller/RepositorySettingsTest.java b/application/src/test/java/org/thingsboard/server/controller/RepositorySettingsTest.java new file mode 100644 index 0000000000..9cd75e9a3c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/RepositorySettingsTest.java @@ -0,0 +1,79 @@ +/** + * 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. + */ +package org.thingsboard.server.controller; + +import com.google.common.util.concurrent.SettableFuture; +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.thingsboard.server.common.data.sync.vc.RepositoryAuthMethod; +import org.thingsboard.server.common.data.sync.vc.RepositorySettings; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.service.sync.vc.GitVersionControlQueueService; + +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Slf4j +@DaoSqlTest +public class RepositorySettingsTest extends AbstractControllerTest { + + @MockBean + private GitVersionControlQueueService gitVersionControlQueueService; + + @Test + public void testFindRepositorySettings() throws Exception { + loginTenantAdmin(); + doGet("/api/admin/repositorySettings") + .andExpect(status().isNotFound()); + + String testRepositoryUri = "https://github.com/test/version-control-test-repository.git"; + + SettableFuture successFuture = SettableFuture.create(); + successFuture.set(null); + when(gitVersionControlQueueService.initRepository(any(), any())) + .thenReturn(successFuture); + + RepositorySettings repositorySettings = new RepositorySettings(); + repositorySettings.setPassword("test"); + repositorySettings.setAuthMethod(RepositoryAuthMethod.USERNAME_PASSWORD); + repositorySettings.setRepositoryUri(testRepositoryUri); + repositorySettings.setDefaultBranch("main"); + doPost("/api/admin/repositorySettings", repositorySettings) + .andExpect(status().isOk()); + + // check repository settings + doGet("/api/admin/repositorySettings") + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$.repositoryUri", is(testRepositoryUri))); + + // delete settings + when(gitVersionControlQueueService.clearRepository(any())) + .thenReturn(successFuture); + doDelete("/api/admin/repositorySettings") + .andExpect(status().isOk()); + + // check repository settings + doGet("/api/admin/repositorySettings") + .andExpect(status().isNotFound()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java index 3f2434c4ec..4fd613dc1d 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java @@ -664,34 +664,38 @@ public class TenantControllerTest extends AbstractControllerTest { savedDifferentTenant.setTenantProfileId(tenantProfile.getId()); savedDifferentTenant = saveTenant(savedDifferentTenant); TenantId tenantId = differentTenantId; - await().atMost(TIMEOUT, TimeUnit.SECONDS) - .until(() -> { - TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_RULE_ENGINE, MAIN_QUEUE_NAME, tenantId, tenantId); - return !tpi.getTenantId().get().isSysTenantId(); - }); - TopicPartitionInfo tpi = new TopicPartitionInfo(MAIN_QUEUE_TOPIC, tenantId, 0, false); - String isolatedTopic = tpi.getFullTopicName(); - TbMsg expectedMsg = publishTbMsg(tenantId, tpi); + List isolatedTpis = await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> { + List newTpis = new ArrayList<>(); + newTpis.add(partitionService.resolve(ServiceType.TB_RULE_ENGINE, MAIN_QUEUE_NAME, tenantId, tenantId)); + newTpis.add(partitionService.resolve(ServiceType.TB_RULE_ENGINE, DataConstants.CF_QUEUE_NAME, tenantId, tenantId)); + return newTpis; + }, newTpis -> newTpis.stream().allMatch(newTpi -> newTpi.getTenantId().get().equals(tenantId))); + TbMsg expectedMsg = publishTbMsg(tenantId, isolatedTpis.get(0)); awaitTbMsg(tbMsg -> tbMsg.getId().equals(expectedMsg.getId()), 10000); // to wait for consumer start loginSysAdmin(); tenantProfile.setIsolatedTbRuleEngine(false); tenantProfile.getProfileData().setQueueConfiguration(Collections.emptyList()); tenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); - await().atMost(TIMEOUT, TimeUnit.SECONDS) - .until(() -> partitionService.resolve(ServiceType.TB_RULE_ENGINE, MAIN_QUEUE_NAME, tenantId, tenantId) - .getTenantId().get().isSysTenantId()); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + TopicPartitionInfo newTpi = partitionService.resolve(ServiceType.TB_RULE_ENGINE, MAIN_QUEUE_NAME, tenantId, tenantId); + assertThat(newTpi.getTenantId()).hasValue(TenantId.SYS_TENANT_ID); + newTpi = partitionService.resolve(ServiceType.TB_RULE_ENGINE, DataConstants.CF_QUEUE_NAME, tenantId, tenantId); + assertThat(newTpi.getTenantId()).hasValue(TenantId.SYS_TENANT_ID); + }); List submittedMsgs = new ArrayList<>(); long timeLeft = TimeUnit.SECONDS.toMillis(7); // based on topic-deletion-delay int msgs = 100; for (int i = 1; i <= msgs; i++) { - TbMsg tbMsg = publishTbMsg(tenantId, tpi); + TbMsg tbMsg = publishTbMsg(tenantId, isolatedTpis.get(0)); submittedMsgs.add(tbMsg.getId()); Thread.sleep(timeLeft / msgs); } await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { - verify(queueAdmin, times(1)).deleteTopic(eq(isolatedTopic)); + TopicPartitionInfo tpi = isolatedTpis.get(0); + // we only expect deletion of Rule Engine topic. for CF - the topic is left as is because queue draining is not supported + verify(queueAdmin, times(1)).deleteTopic(eq(tpi.getFullTopicName())); }); await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { @@ -719,12 +723,16 @@ public class TenantControllerTest extends AbstractControllerTest { savedDifferentTenant.setTenantProfileId(tenantProfile.getId()); savedDifferentTenant = saveTenant(savedDifferentTenant); TenantId tenantId = differentTenantId; - await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { - assertThat(partitionService.getMyPartitions(new QueueKey(ServiceType.TB_RULE_ENGINE, tenantId))).isNotNull(); - }); - TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_RULE_ENGINE, tenantId, tenantId); - assertThat(tpi.getTenantId()).hasValue(tenantId); - TbMsg tbMsg = publishTbMsg(tenantId, tpi); + List isolatedTpis = await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> { + List newTpis = new ArrayList<>(); + newTpis.add(partitionService.resolve(ServiceType.TB_RULE_ENGINE, MAIN_QUEUE_NAME, tenantId, tenantId)); + newTpis.add(partitionService.resolve(ServiceType.TB_RULE_ENGINE, DataConstants.CF_QUEUE_NAME, tenantId, tenantId)); + return newTpis; + }, newTpis -> newTpis.stream().allMatch(newTpi -> { + return newTpi.getTenantId().get().equals(tenantId) && + newTpi.isMyPartition(); + })); + TbMsg tbMsg = publishTbMsg(tenantId, isolatedTpis.get(0)); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { verify(actorContext).tell(argThat(msg -> { return msg instanceof QueueToRuleEngineMsg && ((QueueToRuleEngineMsg) msg).getMsg().getId().equals(tbMsg.getId()); @@ -738,7 +746,9 @@ public class TenantControllerTest extends AbstractControllerTest { assertThatThrownBy(() -> partitionService.resolve(ServiceType.TB_RULE_ENGINE, tenantId, tenantId)) .isInstanceOf(TenantNotFoundException.class); - verify(queueAdmin).deleteTopic(eq(tpi.getFullTopicName())); + isolatedTpis.forEach(tpi -> { + verify(queueAdmin).deleteTopic(eq(tpi.getFullTopicName())); + }); }); } diff --git a/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java index 6d953c1722..0daa56728b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java @@ -37,6 +37,7 @@ import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileCon import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.common.data.tenant.profile.TenantProfileQueueConfiguration; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.queue.TbQueueCallback; import java.util.ArrayList; import java.util.Collections; @@ -44,6 +45,7 @@ import java.util.List; import java.util.stream.Collectors; import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -354,7 +356,7 @@ public class TenantProfileControllerTest extends AbstractControllerTest { argument -> argument.getClass().equals(TenantProfile.class); if (ComponentLifecycleEvent.DELETED.equals(event)) { Mockito.verify(tbClusterService, times(cntTime)).onTenantProfileDelete(Mockito.argThat(matcherTenantProfile), - Mockito.isNull()); + eq(TbQueueCallback.EMPTY)); testBroadcastEntityStateChangeEventNever(createEntityId_NULL_UUID(new Tenant())); } else { Mockito.verify(tbClusterService, times(cntTime)).onTenantProfileChange(Mockito.argThat(matcherTenantProfile), diff --git a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java b/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java index c33e9f7202..9801907d3b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java @@ -83,7 +83,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @Slf4j @DaoSqlTest @TestPropertySource(properties = { - "server.ws.alarms_per_alarm_status_subscription_cache_size=5" + "server.ws.alarms_per_alarm_status_subscription_cache_size=5", + "server.ws.dynamic_page_link.refresh_interval=15" }) public class WebsocketApiTest extends AbstractControllerTest { @Autowired @@ -324,6 +325,83 @@ public class WebsocketApiTest extends AbstractControllerTest { Assert.assertEquals(1, update.getCount()); } + @Test + public void testAlarmCountWsCmdWithSingleEntityFilter() throws Exception { + loginTenantAdmin(); + + SingleEntityFilter singleEntityFilter = new SingleEntityFilter(); + singleEntityFilter.setSingleEntity(tenantId); + AlarmCountQuery alarmCountQuery = new AlarmCountQuery(singleEntityFilter); + AlarmCountCmd cmd1 = new AlarmCountCmd(1, alarmCountQuery); + + getWsClient().send(cmd1); + + AlarmCountUpdate update = getWsClient().parseAlarmCountReply(getWsClient().waitForReply()); + Assert.assertEquals(1, update.getCmdId()); + Assert.assertEquals(0, update.getCount()); + + //create alarm, check count = 1 + getWsClient().registerWaitForUpdate(); + + Alarm alarm = new Alarm(); + alarm.setOriginator(tenantId); + alarm.setType("TEST ALARM"); + alarm.setSeverity(AlarmSeverity.WARNING); + alarm = doPost("/api/alarm", alarm, Alarm.class); + + update = getWsClient().parseAlarmCountReply(getWsClient().waitForUpdate()); + Assert.assertEquals(1, update.getCmdId()); + Assert.assertEquals(1, update.getCount()); + + // set wrong entity id in filter, check count = 0 + singleEntityFilter.setSingleEntity(tenantAdminUserId); + AlarmCountCmd cmd3 = new AlarmCountCmd(2, alarmCountQuery); + + getWsClient().send(cmd3); + + update = getWsClient().parseAlarmCountReply(getWsClient().waitForReply()); + Assert.assertEquals(2, update.getCmdId()); + Assert.assertEquals(0, update.getCount()); + } + + @Test + public void testAlarmCountWsCmdWithDeviceType() throws Exception { + loginTenantAdmin(); + + DeviceTypeFilter deviceTypeFilter = new DeviceTypeFilter(); + deviceTypeFilter.setDeviceTypes(List.of("default")); + AlarmCountQuery alarmCountQuery = new AlarmCountQuery(deviceTypeFilter); + AlarmCountCmd cmd1 = new AlarmCountCmd(1, alarmCountQuery); + + getWsClient().send(cmd1); + + AlarmCountUpdate update = getWsClient().parseAlarmCountReply(getWsClient().waitForReply()); + Assert.assertEquals(1, update.getCmdId()); + Assert.assertEquals(0, update.getCount()); + + getWsClient().registerWaitForUpdate(); + + Alarm alarm = new Alarm(); + alarm.setOriginator(device.getId()); + alarm.setType("TEST ALARM"); + alarm.setSeverity(AlarmSeverity.WARNING); + + alarm = doPost("/api/alarm", alarm, Alarm.class); + + update = getWsClient().parseAlarmCountReply(getWsClient().waitForUpdate()); + Assert.assertEquals(1, update.getCmdId()); + Assert.assertEquals(1, update.getCount()); + + deviceTypeFilter.setDeviceTypes(List.of("non-existing")); + AlarmCountCmd cmd3 = new AlarmCountCmd(3, alarmCountQuery); + + getWsClient().send(cmd3); + + update = getWsClient().parseAlarmCountReply(getWsClient().waitForReply()); + Assert.assertEquals(3, update.getCmdId()); + Assert.assertEquals(0, update.getCount()); + } + @Test public void testAlarmStatusWsCmd() throws Exception { loginTenantAdmin(); @@ -372,17 +450,18 @@ public class WebsocketApiTest extends AbstractControllerTest { doPost("/api/alarm", alarm2, Alarm.class); - AlarmStatusUpdate alarmStatusUpdate3 = JacksonUtil.fromString(getWsClient().waitForReply(), AlarmStatusUpdate.class); + AlarmStatusUpdate alarmStatusUpdate3 = JacksonUtil.fromString(getWsClient().waitForUpdate(), AlarmStatusUpdate.class); Assert.assertEquals(1, alarmStatusUpdate3.getCmdId()); Assert.assertTrue(alarmStatusUpdate3.isActive()); //change severity + getWsClient().registerWaitForUpdate(); alarm2.setSeverity(AlarmSeverity.MAJOR); Alarm updatedAlarm = doPost("/api/alarm", alarm2, Alarm.class); Assert.assertNotNull(updatedAlarm); Assert.assertEquals(AlarmSeverity.MAJOR, updatedAlarm.getSeverity()); - AlarmStatusUpdate alarmStatusUpdate4 = JacksonUtil.fromString(getWsClient().waitForReply(), AlarmStatusUpdate.class); + AlarmStatusUpdate alarmStatusUpdate4 = JacksonUtil.fromString(getWsClient().waitForUpdate(), AlarmStatusUpdate.class); Assert.assertEquals(1, alarmStatusUpdate4.getCmdId()); Assert.assertFalse(alarmStatusUpdate4.isActive()); diff --git a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java index 9212afa7c5..feac7adb04 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java @@ -90,14 +90,12 @@ import org.thingsboard.server.gen.edge.v1.EdgeConfiguration; import org.thingsboard.server.gen.edge.v1.OAuth2ClientUpdateMsg; import org.thingsboard.server.gen.edge.v1.OAuth2DomainUpdateMsg; import org.thingsboard.server.gen.edge.v1.QueueUpdateMsg; -import org.thingsboard.server.gen.edge.v1.RuleChainMetadataRequestMsg; import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg; import org.thingsboard.server.gen.edge.v1.RuleChainUpdateMsg; import org.thingsboard.server.gen.edge.v1.SyncCompletedMsg; import org.thingsboard.server.gen.edge.v1.TenantProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.TenantUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; -import org.thingsboard.server.gen.edge.v1.UplinkMsg; import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg; import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; @@ -142,35 +140,14 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { installation(); edgeImitator = new EdgeImitator("localhost", 7070, edge.getRoutingKey(), edge.getSecret()); + edgeImitator.expectMessageAmount(25); edgeImitator.ignoreType(OAuth2ClientUpdateMsg.class); edgeImitator.ignoreType(OAuth2DomainUpdateMsg.class); - edgeImitator.expectMessageAmount(26); edgeImitator.connect(); - requestEdgeRuleChainMetadata(); - verifyEdgeConnectionAndInitialData(); } - private void requestEdgeRuleChainMetadata() throws Exception { - RuleChainId rootRuleChainId = getEdgeRootRuleChainId(); - RuleChainMetadataRequestMsg.Builder builder = RuleChainMetadataRequestMsg.newBuilder() - .setRuleChainIdMSB(rootRuleChainId.getId().getMostSignificantBits()) - .setRuleChainIdLSB(rootRuleChainId.getId().getLeastSignificantBits()); - testAutoGeneratedCodeByProtobuf(builder); - UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder() - .addRuleChainMetadataRequestMsg(builder.build()); - edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); - } - - private RuleChainId getEdgeRootRuleChainId() throws Exception { - return doGetTypedWithPageLink("/api/ruleChains?type={type}&", new TypeReference>() { - }, - new PageLink(100, 0, "Edge Root Rule Chain"), - "EDGE") - .getData().get(0).getId(); - } - @After public void teardownEdgeTest() { try { @@ -213,6 +190,19 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { doPost("/api/ruleChain/metadata", rootRuleChainMetadata, RuleChainMetaData.class); } + private RuleChainId getEdgeRootRuleChainId() throws Exception { + List edgeRuleChains = doGetTypedWithPageLink("/api/ruleChains?type={type}&", + new TypeReference>() {}, + new PageLink(100, 0, "Edge Root Rule Chain"), + "EDGE").getData(); + for (RuleChain edgeRuleChain : edgeRuleChains) { + if (edgeRuleChain.isRoot()) { + return edgeRuleChain.getId(); + } + } + throw new RuntimeException("Root rule chain not found"); + } + protected void extendDeviceProfileData(DeviceProfile deviceProfile) { DeviceProfileData profileData = deviceProfile.getProfileData(); List alarms = new ArrayList<>(); @@ -255,8 +245,8 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { validateMsgsCnt(RuleChainUpdateMsg.class, 1); UUID ruleChainUUID = validateRuleChains(); - // 1 from request message - validateMsgsCnt(RuleChainMetadataUpdateMsg.class, 2); + // 1 from rule chain fetcher + validateMsgsCnt(RuleChainMetadataUpdateMsg.class, 1); validateRuleChainMetadataUpdates(ruleChainUUID); // 4 messages ('general', 'mail', 'connectivity', 'jwt') @@ -438,12 +428,11 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { } private void validateRuleChainMetadataUpdates(UUID expectedRuleChainUUID) { - Optional ruleChainMetadataUpdateOpt = edgeImitator.findMessageByType(RuleChainMetadataUpdateMsg.class); - Assert.assertTrue(ruleChainMetadataUpdateOpt.isPresent()); - RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg = ruleChainMetadataUpdateOpt.get(); + Optional ruleChainMetadataUpdateMsgOpt = edgeImitator.findMessageByType(RuleChainMetadataUpdateMsg.class); + Assert.assertTrue(ruleChainMetadataUpdateMsgOpt.isPresent()); + RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg = ruleChainMetadataUpdateMsgOpt.get(); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, ruleChainMetadataUpdateMsg.getMsgType()); RuleChainMetaData ruleChainMetaData = JacksonUtil.fromString(ruleChainMetadataUpdateMsg.getEntity(), RuleChainMetaData.class, true); - Assert.assertNotNull(ruleChainMetaData); Assert.assertEquals(expectedRuleChainUUID, ruleChainMetaData.getRuleChainId().getId()); } diff --git a/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java index 79ec2f3d87..4d9840b936 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java @@ -169,6 +169,7 @@ public class AssetEdgeTest extends AbstractEdgeTest { public void testSendAssetToCloud() throws Exception { Asset asset = buildAssetForUplinkMsg("Asset Edge 2"); + // created asset on edge UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); AssetUpdateMsg.Builder assetUpdateMsgBuilder = AssetUpdateMsg.newBuilder(); assetUpdateMsgBuilder.setIdMSB(asset.getUuidId().getMostSignificantBits()); @@ -191,6 +192,32 @@ public class AssetEdgeTest extends AbstractEdgeTest { Asset foundAsset = doGet("/api/asset/" + asset.getUuidId(), Asset.class); Assert.assertNotNull(foundAsset); Assert.assertEquals("Asset Edge 2", foundAsset.getName()); + + // update asset on edge + asset.setName("Asset Edge 2 Updated"); + + uplinkMsgBuilder = UplinkMsg.newBuilder(); + assetUpdateMsgBuilder = AssetUpdateMsg.newBuilder(); + assetUpdateMsgBuilder.setIdMSB(asset.getUuidId().getMostSignificantBits()); + assetUpdateMsgBuilder.setIdLSB(asset.getUuidId().getLeastSignificantBits()); + assetUpdateMsgBuilder.setEntity(JacksonUtil.toString(asset)); + assetUpdateMsgBuilder.setMsgType(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE); + testAutoGeneratedCodeByProtobuf(assetUpdateMsgBuilder); + uplinkMsgBuilder.addAssetUpdateMsg(assetUpdateMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); + + Assert.assertTrue(edgeImitator.waitForResponses()); + + latestResponseMsg = edgeImitator.getLatestResponseMsg(); + Assert.assertTrue(latestResponseMsg.getSuccess()); + + foundAsset = doGet("/api/asset/" + asset.getUuidId(), Asset.class); + Assert.assertNotNull(foundAsset); + Assert.assertEquals("Asset Edge 2 Updated", foundAsset.getName()); } @Test diff --git a/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java index 71fe987813..4df305b2c5 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java @@ -121,14 +121,14 @@ public class AssetProfileEdgeTest extends AbstractEdgeTest { Assert.assertNull(assetProfile.getDefaultRuleChainId()); Assert.assertEquals(edgeRuleChainId, assetProfile.getDefaultEdgeRuleChainId()); - // delete profile - edgeImitator.expectMessageAmount(1); + // delete profile and delete relation messages + edgeImitator.expectMessageAmount(2); doDelete("/api/assetProfile/" + assetProfile.getUuidId()) .andExpect(status().isOk()); Assert.assertTrue(edgeImitator.waitForMessages()); - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof AssetProfileUpdateMsg); - AssetProfileUpdateMsg assetProfileUpdateMsg = (AssetProfileUpdateMsg) latestMessage; + Optional assetDeleteMsgOpt = edgeImitator.findMessageByType(AssetProfileUpdateMsg.class); + Assert.assertTrue(assetDeleteMsgOpt.isPresent()); + AssetProfileUpdateMsg assetProfileUpdateMsg = assetDeleteMsgOpt.get(); Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, assetProfileUpdateMsg.getMsgType()); Assert.assertEquals(assetProfile.getUuidId().getMostSignificantBits(), assetProfileUpdateMsg.getIdMSB()); Assert.assertEquals(assetProfile.getUuidId().getLeastSignificantBits(), assetProfileUpdateMsg.getIdLSB()); diff --git a/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java index b848178c58..bbf3d17f0d 100644 --- a/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java @@ -184,6 +184,7 @@ public class DashboardEdgeTest extends AbstractEdgeTest { Dashboard dashboard = buildDashboardForUplinkMsg(savedCustomer); + // create dashboard on edge UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); DashboardUpdateMsg.Builder dashboardUpdateMsgBuilder = DashboardUpdateMsg.newBuilder(); dashboardUpdateMsgBuilder.setIdMSB(dashboard.getUuidId().getMostSignificantBits()); diff --git a/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java index a6cd127097..da8e7563e0 100644 --- a/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java @@ -593,8 +593,10 @@ public class DeviceEdgeTest extends AbstractEdgeTest { @Test public void testSendDeviceToCloud() throws Exception { - Device deviceMsg = buildDeviceForUplinkMsg("Edge Device 2", "test"); + String deviceName = "Edge Device 2"; + Device deviceMsg = buildDeviceForUplinkMsg(deviceName, "test"); + // create device on edge UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); DeviceUpdateMsg.Builder deviceUpdateMsgBuilder = DeviceUpdateMsg.newBuilder(); deviceUpdateMsgBuilder.setIdMSB(deviceMsg.getUuidId().getMostSignificantBits()); @@ -609,7 +611,25 @@ public class DeviceEdgeTest extends AbstractEdgeTest { Device device = doGet("/api/device/" + deviceMsg.getId().getId(), Device.class); Assert.assertNotNull(device); - Assert.assertEquals("Edge Device 2", device.getName()); + Assert.assertEquals(deviceName, device.getName()); + + // update device on edge + deviceMsg.setName(deviceName + " Updated"); + uplinkMsgBuilder = UplinkMsg.newBuilder(); + deviceUpdateMsgBuilder = DeviceUpdateMsg.newBuilder(); + deviceUpdateMsgBuilder.setIdMSB(deviceMsg.getUuidId().getMostSignificantBits()); + deviceUpdateMsgBuilder.setIdLSB(deviceMsg.getUuidId().getLeastSignificantBits()); + deviceUpdateMsgBuilder.setEntity(JacksonUtil.toString(deviceMsg)); + deviceUpdateMsgBuilder.setMsgType(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE); + uplinkMsgBuilder.addDeviceUpdateMsg(deviceUpdateMsgBuilder.build()); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); + Assert.assertTrue(edgeImitator.waitForResponses()); + + device = doGet("/api/device/" + deviceMsg.getId().getId(), Device.class); + Assert.assertNotNull(device); + Assert.assertEquals(deviceName + " Updated", device.getName()); } @Test diff --git a/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java index f1c4f31f65..819dec5c0d 100644 --- a/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java @@ -327,14 +327,14 @@ public class DeviceProfileEdgeTest extends AbstractEdgeTest { Assert.assertNotNull(deviceProfile); Assert.assertEquals("Device Profile On Edge", deviceProfile.getName()); - // delete profile - edgeImitator.expectMessageAmount(1); + // delete profile and delete relation messages + edgeImitator.expectMessageAmount(2); doDelete("/api/deviceProfile/" + deviceProfile.getUuidId()) .andExpect(status().isOk()); Assert.assertTrue(edgeImitator.waitForMessages()); - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof DeviceProfileUpdateMsg); - DeviceProfileUpdateMsg deviceProfileUpdateMsg = (DeviceProfileUpdateMsg) latestMessage; + Optional deviceDeleteMsgOpt = edgeImitator.findMessageByType(DeviceProfileUpdateMsg.class); + Assert.assertTrue(deviceDeleteMsgOpt.isPresent()); + DeviceProfileUpdateMsg deviceProfileUpdateMsg = deviceDeleteMsgOpt.get(); Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, deviceProfileUpdateMsg.getMsgType()); Assert.assertEquals(deviceProfile.getUuidId().getMostSignificantBits(), deviceProfileUpdateMsg.getIdMSB()); Assert.assertEquals(deviceProfile.getUuidId().getLeastSignificantBits(), deviceProfileUpdateMsg.getIdLSB()); diff --git a/application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java index a5fea1694a..7c029e0471 100644 --- a/application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java @@ -15,7 +15,7 @@ */ package org.thingsboard.server.edge; -import com.google.protobuf.AbstractMessage; +import com.datastax.oss.driver.api.core.uuid.Uuids; import org.junit.Assert; import org.junit.Test; import org.thingsboard.common.util.JacksonUtil; @@ -29,16 +29,17 @@ import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.dao.service.DaoSqlTest; -import org.thingsboard.server.gen.edge.v1.RuleChainMetadataRequestMsg; import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg; import org.thingsboard.server.gen.edge.v1.RuleChainUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.UUID; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -74,8 +75,9 @@ public class RuleChainEdgeTest extends AbstractEdgeTest { RuleChainMetaData ruleChainMetaData = JacksonUtil.fromString(ruleChainMetadataUpdateMsg.getEntity(), RuleChainMetaData.class, true); Assert.assertNotNull(ruleChainMetaData); Assert.assertEquals(ruleChainMetaData.getRuleChainId(), savedRuleChain.getId()); - - testRuleChainMetadataRequestMsg(savedRuleChain.getId()); + for (RuleNode ruleNode : ruleChainMetaData.getNodes()) { + Assert.assertEquals(CONFIGURATION_VERSION, ruleNode.getConfigurationVersion()); + } // unassign rule chain from edge edgeImitator.expectMessageAmount(1); @@ -97,60 +99,62 @@ public class RuleChainEdgeTest extends AbstractEdgeTest { } @Test - public void testSendRuleChainMetadataRequestToCloud() throws Exception { - RuleChainId edgeRootRuleChainId = edge.getRootRuleChainId(); - + public void testRuleChainToCloud() throws Exception { + String ruleChainName = "Rule Chain Edge"; + UUID uuid = Uuids.timeBased(); + + // create rule chain on edge + RuleChain edgeRuleChain = new RuleChain(); + edgeRuleChain.setTenantId(tenantId); + edgeRuleChain.setId(new RuleChainId(uuid)); + edgeRuleChain.setName(ruleChainName); UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); - RuleChainMetadataRequestMsg.Builder ruleChainMetadataRequestMsgBuilder = RuleChainMetadataRequestMsg.newBuilder(); - ruleChainMetadataRequestMsgBuilder.setRuleChainIdMSB(edgeRootRuleChainId.getId().getMostSignificantBits()); - ruleChainMetadataRequestMsgBuilder.setRuleChainIdLSB(edgeRootRuleChainId.getId().getLeastSignificantBits()); - testAutoGeneratedCodeByProtobuf(ruleChainMetadataRequestMsgBuilder); - uplinkMsgBuilder.addRuleChainMetadataRequestMsg(ruleChainMetadataRequestMsgBuilder.build()); + RuleChainUpdateMsg.Builder ruleChainUpdateMsgBuilder = RuleChainUpdateMsg.newBuilder(); + ruleChainUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + ruleChainUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + ruleChainUpdateMsgBuilder.setEntity(JacksonUtil.toString(edgeRuleChain)); + ruleChainUpdateMsgBuilder.setMsgType(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + testAutoGeneratedCodeByProtobuf(ruleChainUpdateMsgBuilder); + uplinkMsgBuilder.addRuleChainUpdateMsg(ruleChainUpdateMsgBuilder.build()); testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); edgeImitator.expectResponsesAmount(1); - edgeImitator.expectMessageAmount(1); edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); + Assert.assertTrue(edgeImitator.waitForResponses()); - Assert.assertTrue(edgeImitator.waitForMessages()); - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof RuleChainMetadataUpdateMsg); - RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg = (RuleChainMetadataUpdateMsg) latestMessage; - RuleChainMetaData ruleChainMetadataMsg = JacksonUtil.fromString(ruleChainMetadataUpdateMsg.getEntity(), RuleChainMetaData.class, true); - Assert.assertNotNull(ruleChainMetadataMsg); - Assert.assertEquals(edgeRootRuleChainId, ruleChainMetadataMsg.getRuleChainId()); + UplinkResponseMsg latestResponseMsg = edgeImitator.getLatestResponseMsg(); + Assert.assertTrue(latestResponseMsg.getSuccess()); - testAutoGeneratedCodeByProtobuf(ruleChainMetadataUpdateMsg); - } + RuleChain ruleChain = doGet("/api/ruleChain/" + uuid, RuleChain.class); + Assert.assertNotNull(ruleChain); + Assert.assertEquals("Rule Chain Edge", ruleChain.getName()); - private void testRuleChainMetadataRequestMsg(RuleChainId ruleChainId) throws Exception { - RuleChainMetadataRequestMsg.Builder ruleChainMetadataRequestMsgBuilder = RuleChainMetadataRequestMsg.newBuilder() - .setRuleChainIdMSB(ruleChainId.getId().getMostSignificantBits()) - .setRuleChainIdLSB(ruleChainId.getId().getLeastSignificantBits()); - testAutoGeneratedCodeByProtobuf(ruleChainMetadataRequestMsgBuilder); + // update rule chain on edge + edgeRuleChain.setName(ruleChainName + " Updated"); + uplinkMsgBuilder = UplinkMsg.newBuilder(); + ruleChainUpdateMsgBuilder = RuleChainUpdateMsg.newBuilder(); + ruleChainUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + ruleChainUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + ruleChainUpdateMsgBuilder.setEntity(JacksonUtil.toString(edgeRuleChain)); + ruleChainUpdateMsgBuilder.setMsgType(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE); + testAutoGeneratedCodeByProtobuf(ruleChainUpdateMsgBuilder); + uplinkMsgBuilder.addRuleChainUpdateMsg(ruleChainUpdateMsgBuilder.build()); - UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder() - .addRuleChainMetadataRequestMsg(ruleChainMetadataRequestMsgBuilder.build()); testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); edgeImitator.expectResponsesAmount(1); - edgeImitator.expectMessageAmount(1); edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); + Assert.assertTrue(edgeImitator.waitForResponses()); - Assert.assertTrue(edgeImitator.waitForMessages()); - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof RuleChainMetadataUpdateMsg); - RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg = (RuleChainMetadataUpdateMsg) latestMessage; - RuleChainMetaData ruleChainMetadataMsg = JacksonUtil.fromString(ruleChainMetadataUpdateMsg.getEntity(), RuleChainMetaData.class, true); - Assert.assertNotNull(ruleChainMetadataMsg); - Assert.assertEquals(ruleChainId, ruleChainMetadataMsg.getRuleChainId()); + latestResponseMsg = edgeImitator.getLatestResponseMsg(); + Assert.assertTrue(latestResponseMsg.getSuccess()); - for (RuleNode ruleNode : ruleChainMetadataMsg.getNodes()) { - Assert.assertEquals(CONFIGURATION_VERSION, ruleNode.getConfigurationVersion()); - } + ruleChain = doGet("/api/ruleChain/" + uuid, RuleChain.class); + Assert.assertNotNull(ruleChain); + Assert.assertEquals(ruleChainName + " Updated", ruleChain.getName()); } private RuleChainMetaData createRuleChainMetadata(RuleChain ruleChain) { diff --git a/application/src/test/java/org/thingsboard/server/queue/discovery/HashPartitionServiceTest.java b/application/src/test/java/org/thingsboard/server/queue/discovery/HashPartitionServiceTest.java index 2eb4bde15e..dace159774 100644 --- a/application/src/test/java/org/thingsboard/server/queue/discovery/HashPartitionServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/queue/discovery/HashPartitionServiceTest.java @@ -153,7 +153,7 @@ public class HashPartitionServiceTest { for (int queueIndex = 0; queueIndex < queueCount; queueIndex++) { QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, "queue" + queueIndex, tenantId); for (int partition = 0; partition < partitionCount; partition++) { - ServiceInfo serviceInfo = partitionService.resolveByPartitionIdx(services, queueKey, partition, Collections.emptyMap()); + ServiceInfo serviceInfo = partitionService.resolveByPartitionIdx(services, queueKey, partition, Collections.emptyMap()).get(0); String serviceId = serviceInfo.getServiceId(); map.put(serviceId, map.get(serviceId) + 1); } @@ -308,7 +308,7 @@ public class HashPartitionServiceTest { partitionService_common.recalculatePartitions(commonRuleEngine, List.of(dedicatedRuleEngine)); verifyPartitionChangeEvent(event -> { QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, DataConstants.MAIN_QUEUE_NAME, TenantId.SYS_TENANT_ID); - return event.getPartitionsMap().get(queueKey).size() == systemQueue.getPartitions(); + return event.getNewPartitions().get(queueKey).size() == systemQueue.getPartitions(); }); Mockito.reset(applicationEventPublisher); @@ -336,14 +336,14 @@ public class HashPartitionServiceTest { // expecting event about no partitions for isolated queue key verifyPartitionChangeEvent(event -> { QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, DataConstants.MAIN_QUEUE_NAME, tenantId); - return event.getPartitionsMap().get(queueKey).isEmpty(); + return event.getNewPartitions().get(queueKey).isEmpty(); }); partitionService_dedicated.updateQueues(List.of(queueUpdateMsg)); partitionService_dedicated.recalculatePartitions(dedicatedRuleEngine, List.of(commonRuleEngine)); verifyPartitionChangeEvent(event -> { QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, DataConstants.MAIN_QUEUE_NAME, tenantId); - return event.getPartitionsMap().get(queueKey).size() == isolatedQueue.getPartitions(); + return event.getNewPartitions().get(queueKey).size() == isolatedQueue.getPartitions(); }); @@ -361,7 +361,7 @@ public class HashPartitionServiceTest { partitionService_dedicated.removeQueues(List.of(queueDeleteMsg)); verifyPartitionChangeEvent(event -> { QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, DataConstants.MAIN_QUEUE_NAME, tenantId); - return event.getPartitionsMap().get(queueKey).isEmpty(); + return event.getNewPartitions().get(queueKey).isEmpty(); }); } @@ -381,12 +381,12 @@ public class HashPartitionServiceTest { Stream.concat(Stream.of(TenantId.SYS_TENANT_ID), Stream.generate(UUID::randomUUID).map(TenantId::new).limit(10)).forEach(tenantId -> { List queues = Stream.generate(() -> RandomStringUtils.randomAlphabetic(10)) .map(queueName -> new QueueKey(ServiceType.TB_RULE_ENGINE, queueName, tenantId)) - .limit(100).collect(Collectors.toList()); + .limit(100).toList(); for (int partition = 0; partition < 10; partition++) { - ServiceInfo expectedAssignedRuleEngine = partitionService.resolveByPartitionIdx(ruleEngines, new QueueKey(ServiceType.TB_RULE_ENGINE, tenantId), partition, Collections.emptyMap()); + ServiceInfo expectedAssignedRuleEngine = partitionService.resolveByPartitionIdx(ruleEngines, new QueueKey(ServiceType.TB_RULE_ENGINE, tenantId), partition, Collections.emptyMap()).get(0); for (QueueKey queueKey : queues) { - ServiceInfo assignedRuleEngine = partitionService.resolveByPartitionIdx(ruleEngines, queueKey, partition, Collections.emptyMap()); + ServiceInfo assignedRuleEngine = partitionService.resolveByPartitionIdx(ruleEngines, queueKey, partition, Collections.emptyMap()).get(0); assertThat(assignedRuleEngine).as(queueKey + "[" + partition + "] should be assigned to " + expectedAssignedRuleEngine.getServiceId()) .isEqualTo(expectedAssignedRuleEngine); } @@ -426,11 +426,14 @@ public class HashPartitionServiceTest { topicService); ReflectionTestUtils.setField(partitionService, "coreTopic", "tb.core"); ReflectionTestUtils.setField(partitionService, "corePartitions", 10); + ReflectionTestUtils.setField(partitionService, "cfEventTopic", "tb_cf_event"); + ReflectionTestUtils.setField(partitionService, "cfStateTopic", "tb_cf_state"); ReflectionTestUtils.setField(partitionService, "vcTopic", "tb.vc"); ReflectionTestUtils.setField(partitionService, "vcPartitions", 10); ReflectionTestUtils.setField(partitionService, "hashFunctionName", hashFunctionName); ReflectionTestUtils.setField(partitionService, "edgeTopic", "tb.edge"); ReflectionTestUtils.setField(partitionService, "edgePartitions", 10); + ReflectionTestUtils.setField(partitionService, "edqsPartitions", 12); partitionService.init(); partitionService.partitionsInit(); return partitionService; diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java new file mode 100644 index 0000000000..91eced64f6 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java @@ -0,0 +1,207 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.ctx.state; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.script.api.tbel.DefaultTbelInvokeService; +import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.stats.DefaultStatsFactory; +import org.thingsboard.server.dao.usagerecord.ApiLimitService; +import org.thingsboard.server.service.cf.CalculatedFieldResult; + +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; +import java.util.UUID; +import java.util.concurrent.ExecutionException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@SpringBootTest(classes = {SimpleMeterRegistry.class, DefaultStatsFactory.class, DefaultTbelInvokeService.class}) +public class ScriptCalculatedFieldStateTest { + + private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("5b18e321-3327-4290-b996-d72a65e90382")); + private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("5512071d-5abc-411d-a907-4cdb6539c2eb")); + private final AssetId ASSET_ID = new AssetId(UUID.fromString("5bc010ae-bcfd-46c8-98b9-8ee8c8955a76")); + + private final SingleValueArgumentEntry assetHumidityArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis() - 10, new DoubleDataEntry("assetHumidity", 43.0), 122L); + private final TsRollingArgumentEntry deviceTemperatureArgEntry = createRollingArgEntry(); + + private final long ts = System.currentTimeMillis(); + + private ScriptCalculatedFieldState state; + private CalculatedFieldCtx ctx; + + @Autowired + private TbelInvokeService tbelInvokeService; + + @MockBean + private ApiLimitService apiLimitService; + + @BeforeEach + void setUp() { + when(apiLimitService.getLimit(any(), any())).thenReturn(1000L); + ctx = new CalculatedFieldCtx(getCalculatedField(), tbelInvokeService, apiLimitService); + ctx.init(); + state = new ScriptCalculatedFieldState(ctx.getArgNames()); + } + + @Test + void testType() { + assertThat(state.getType()).isEqualTo(CalculatedFieldType.SCRIPT); + } + + @Test + void testUpdateState() { + state.arguments = new HashMap<>(Map.of("assetHumidity", assetHumidityArgEntry)); + + Map newArgs = Map.of("deviceTemperature", deviceTemperatureArgEntry); + boolean stateUpdated = state.updateState(ctx, newArgs); + + assertThat(stateUpdated).isTrue(); + assertThat(state.getArguments()).containsExactlyInAnyOrderEntriesOf( + Map.of( + "assetHumidity", assetHumidityArgEntry, + "deviceTemperature", deviceTemperatureArgEntry + ) + ); + } + + @Test + void testUpdateStateWhenUpdateExistingEntry() { + state.arguments = new HashMap<>(Map.of("deviceTemperature", deviceTemperatureArgEntry, "assetHumidity", assetHumidityArgEntry)); + + SingleValueArgumentEntry newArgEntry = new SingleValueArgumentEntry(ts, new LongDataEntry("assetHumidity", 41L), 349L); + Map newArgs = Map.of("assetHumidity", newArgEntry); + boolean stateUpdated = state.updateState(ctx, newArgs); + + assertThat(stateUpdated).isTrue(); + assertThat(state.getArguments()).containsExactlyInAnyOrderEntriesOf( + Map.of( + "assetHumidity", newArgEntry, + "deviceTemperature", deviceTemperatureArgEntry + ) + ); + } + + @Test + void testPerformCalculation() throws ExecutionException, InterruptedException { + state.arguments = new HashMap<>(Map.of("deviceTemperature", deviceTemperatureArgEntry, "assetHumidity", assetHumidityArgEntry)); + + CalculatedFieldResult result = state.performCalculation(ctx).get(); + + assertThat(result).isNotNull(); + Output output = getCalculatedFieldConfig().getOutput(); + assertThat(result.getType()).isEqualTo(output.getType()); + assertThat(result.getScope()).isEqualTo(output.getScope()); + assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("maxDeviceTemperature", 17.0, "assetHumidity", 43.0))); + } + + @Test + void testIsReadyWhenNotAllArgPresent() { + assertThat(state.isReady()).isFalse(); + } + + @Test + void testIsReadyWhenAllArgPresent() { + state.arguments = new HashMap<>(Map.of("deviceTemperature", deviceTemperatureArgEntry, "assetHumidity", assetHumidityArgEntry)); + + assertThat(state.isReady()).isTrue(); + } + + @Test + void testIsReadyWhenEmptyEntryPresents() { + state.arguments = new HashMap<>(Map.of("deviceTemperature", new TsRollingArgumentEntry(5, 30000L), "assetHumidity", assetHumidityArgEntry)); + + assertThat(state.isReady()).isFalse(); + } + + private TsRollingArgumentEntry createRollingArgEntry() { + TsRollingArgumentEntry argumentEntry = new TsRollingArgumentEntry(5, 30000L); + long ts = System.currentTimeMillis(); + + TreeMap values = new TreeMap<>(); + values.put(ts - 40, 10.0); + values.put(ts - 30, 12.0); + values.put(ts - 20, 17.0); + + argumentEntry.setTsRecords(values); + return argumentEntry; + } + + private CalculatedField getCalculatedField() { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setTenantId(TENANT_ID); + calculatedField.setEntityId(ASSET_ID); + calculatedField.setType(CalculatedFieldType.SCRIPT); + calculatedField.setName("Test Calculated Field"); + calculatedField.setConfigurationVersion(1); + calculatedField.setConfiguration(getCalculatedFieldConfig()); + calculatedField.setVersion(1L); + return calculatedField; + } + + private CalculatedFieldConfiguration getCalculatedFieldConfig() { + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument1 = new Argument(); + argument1.setRefEntityId(DEVICE_ID); + ReferencedEntityKey refEntityKey1 = new ReferencedEntityKey("temperature", ArgumentType.TS_ROLLING, null); + argument1.setRefEntityKey(refEntityKey1); + argument1.setLimit(5); + argument1.setTimeWindow(30000L); + + Argument argument2 = new Argument(); + ReferencedEntityKey refEntityKey2 = new ReferencedEntityKey("humidity", ArgumentType.TS_LATEST, null); + argument1.setRefEntityKey(refEntityKey2); + + config.setArguments(Map.of("deviceTemperature", argument1, "assetHumidity", argument2)); + + config.setExpression("return {\"maxDeviceTemperature\": deviceTemperature.max(), \"assetHumidity\": assetHumidity}"); + + Output output = new Output(); + output.setType(OutputType.ATTRIBUTES); + output.setScope(AttributeScope.SERVER_SCOPE); + + config.setOutput(output); + + return config; + } + +} \ No newline at end of file diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java new file mode 100644 index 0000000000..f0059e4f6f --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java @@ -0,0 +1,250 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.ctx.state; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.dao.usagerecord.ApiLimitService; +import org.thingsboard.server.service.cf.CalculatedFieldResult; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutionException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class SimpleCalculatedFieldStateTest { + + private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("5b18e321-3327-4290-b996-d72a65e90382")); + private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("5512071d-5abc-411d-a907-4cdb6539c2eb")); + private final AssetId ASSET_ID = new AssetId(UUID.fromString("5bc010ae-bcfd-46c8-98b9-8ee8c8955a76")); + + private final SingleValueArgumentEntry key1ArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis() - 10, new LongDataEntry("key1", 11L), 145L); + private final SingleValueArgumentEntry key2ArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis() - 6, new LongDataEntry("key2", 15L), 165L); + private final SingleValueArgumentEntry key3ArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis() - 3, new LongDataEntry("key3", 23L), 184L); + + private SimpleCalculatedFieldState state; + private CalculatedFieldCtx ctx; + + @Mock + private ApiLimitService apiLimitService; + + @BeforeEach + void setUp() { + when(apiLimitService.getLimit(any(), any())).thenReturn(1000L); + ctx = new CalculatedFieldCtx(getCalculatedField(), null, apiLimitService); + ctx.init(); + state = new SimpleCalculatedFieldState(ctx.getArgNames()); + } + + @Test + void testType() { + assertThat(state.getType()).isEqualTo(CalculatedFieldType.SIMPLE); + } + + @Test + void testUpdateState() { + state.arguments = new HashMap<>(Map.of( + "key1", key1ArgEntry, + "key2", key2ArgEntry + )); + + Map newArgs = Map.of("key3", key3ArgEntry); + boolean stateUpdated = state.updateState(ctx, newArgs); + + assertThat(stateUpdated).isTrue(); + assertThat(state.getArguments()).containsExactlyInAnyOrderEntriesOf( + Map.of( + "key1", key1ArgEntry, + "key2", key2ArgEntry, + "key3", key3ArgEntry + ) + ); + } + + @Test + void testUpdateStateWhenUpdateExistingEntry() { + state.arguments = new HashMap<>(Map.of("key1", key1ArgEntry)); + + SingleValueArgumentEntry newArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis(), new LongDataEntry("key1", 18L), 190L); + Map newArgs = Map.of("key1", newArgEntry); + boolean stateUpdated = state.updateState(ctx, newArgs); + + assertThat(stateUpdated).isTrue(); + assertThat(state.getArguments()).containsExactlyInAnyOrderEntriesOf(Map.of("key1", newArgEntry)); + } + + @Test + void testUpdateStateWhenRollingEntryPassed() { + state.arguments = new HashMap<>(Map.of( + "key1", key1ArgEntry, + "key2", key2ArgEntry + )); + + Map newArgs = Map.of("key3", new TsRollingArgumentEntry(10, 30000L)); + assertThatThrownBy(() -> state.updateState(ctx, newArgs)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Rolling argument entry is not supported for simple calculated fields."); + } + + @Test + void testPerformCalculation() throws ExecutionException, InterruptedException { + state.arguments = new HashMap<>(Map.of( + "key1", key1ArgEntry, + "key2", key2ArgEntry, + "key3", key3ArgEntry + )); + + CalculatedFieldResult result = state.performCalculation(ctx).get(); + + assertThat(result).isNotNull(); + Output output = getCalculatedFieldConfig().getOutput(); + assertThat(result.getType()).isEqualTo(output.getType()); + assertThat(result.getScope()).isEqualTo(output.getScope()); + assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 49))); + } + + @Test + void testPerformCalculationWhenPassedNotNumber() { + state.arguments = new HashMap<>(Map.of( + "key1", key1ArgEntry, + "key2", new SingleValueArgumentEntry(System.currentTimeMillis() - 9, new StringDataEntry("key2", "string"), 124L), + "key3", key3ArgEntry + )); + + assertThatThrownBy(() -> state.performCalculation(ctx)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Argument 'key2' is not a number."); + } + + @Test + void testPerformCalculationWhenDecimalsByDefault() throws ExecutionException, InterruptedException { + state.arguments = new HashMap<>(Map.of( + "key1", new SingleValueArgumentEntry(System.currentTimeMillis() - 10, new DoubleDataEntry("key1", 11.3456), 145L), + "key2", new SingleValueArgumentEntry(System.currentTimeMillis() - 6, new DoubleDataEntry("key2", 15.1), 165L), + "key3", new SingleValueArgumentEntry(System.currentTimeMillis() - 3, new DoubleDataEntry("key3", 23.1), 184L) + )); + + Output output = getCalculatedFieldConfig().getOutput(); + output.setDecimalsByDefault(3); + ctx.setOutput(output); + + CalculatedFieldResult result = state.performCalculation(ctx).get(); + + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(output.getType()); + assertThat(result.getScope()).isEqualTo(output.getScope()); + assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 49.546))); + } + + @Test + void testIsReadyWhenNotAllArgPresent() { + assertThat(state.isReady()).isFalse(); + } + + @Test + void testIsReadyWhenAllArgPresent() { + state.arguments = new HashMap<>(Map.of( + "key1", key1ArgEntry, + "key2", key2ArgEntry, + "key3", key3ArgEntry + )); + + assertThat(state.isReady()).isTrue(); + } + + @Test + void testIsReadyWhenEmptyEntryPresents() { + state.arguments = new HashMap<>(Map.of( + "key1", key1ArgEntry, + "key2", key2ArgEntry + )); + state.getArguments().put("key3", new SingleValueArgumentEntry()); + + assertThat(state.isReady()).isFalse(); + } + + private CalculatedField getCalculatedField() { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setTenantId(TENANT_ID); + calculatedField.setEntityId(DEVICE_ID); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("Test Calculated Field"); + calculatedField.setConfigurationVersion(1); + calculatedField.setConfiguration(getCalculatedFieldConfig()); + calculatedField.setVersion(1L); + return calculatedField; + } + + private CalculatedFieldConfiguration getCalculatedFieldConfig() { + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument1 = new Argument(); + argument1.setRefEntityId(ASSET_ID); + ReferencedEntityKey refEntityKey1 = new ReferencedEntityKey("temp1", ArgumentType.TS_LATEST, null); + argument1.setRefEntityKey(refEntityKey1); + + Argument argument2 = new Argument(); + argument2.setRefEntityId(ASSET_ID); + ReferencedEntityKey refEntityKey2 = new ReferencedEntityKey("temp2", ArgumentType.ATTRIBUTE, null); + argument2.setRefEntityKey(refEntityKey2); + + Argument argument3 = new Argument(); + argument3.setRefEntityId(ASSET_ID); + ReferencedEntityKey refEntityKey3 = new ReferencedEntityKey("temp3", ArgumentType.TS_LATEST, null); + argument3.setRefEntityKey(refEntityKey3); + + config.setArguments(Map.of("key1", argument1, "key2", argument2, "key3", argument3)); + + config.setExpression("key1 + key2 + key3"); + + Output output = new Output(); + output.setName("output"); + output.setType(OutputType.ATTRIBUTES); + output.setScope(AttributeScope.SERVER_SCOPE); + output.setDecimalsByDefault(0); + + config.setOutput(output); + + return config; + } + +} \ No newline at end of file diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java new file mode 100644 index 0000000000..2c48ed9167 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java @@ -0,0 +1,76 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.ctx.state; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.kv.LongDataEntry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class SingleValueArgumentEntryTest { + + private SingleValueArgumentEntry entry; + + private final long ts = System.currentTimeMillis(); + + @BeforeEach + void setUp() { + entry = new SingleValueArgumentEntry(ts, new LongDataEntry("key", 11L), 363L); + } + + @Test + void testArgumentEntryType() { + assertThat(entry.getType()).isEqualTo(ArgumentEntryType.SINGLE_VALUE); + } + + @Test + void testUpdateEntryWhenRollingEntryPassed() { + assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported argument entry type for single value argument entry: " + ArgumentEntryType.TS_ROLLING); + } + + @Test + void testUpdateEntryWithThaSameTs() { + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts, new LongDataEntry("key", 13L), 363L))).isFalse(); + } + + @Test + void testUpdateEntryWhenNewVersionIsNull() { + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 16, new LongDataEntry("key", 13L), null))).isTrue(); + assertThat(entry.getValue()).isEqualTo(13L); + assertThat(entry.getVersion()).isNull(); + } + + @Test + void testUpdateEntryWhenNewVersionIsGreaterThanCurrent() { + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 18L), 369L))).isTrue(); + assertThat(entry.getValue()).isEqualTo(18L); + assertThat(entry.getVersion()).isEqualTo(369L); + } + + @Test + void testUpdateEntryWhenNewVersionIsLessThanCurrent() { + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 18L), 234L))).isFalse(); + } + + @Test + void testUpdateEntryWhenValueWasNotChanged() { + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 11L), 364L))).isTrue(); + } +} \ No newline at end of file diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntryTest.java new file mode 100644 index 0000000000..b1f8063857 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntryTest.java @@ -0,0 +1,123 @@ +/** + * 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. + */ +package org.thingsboard.server.service.cf.ctx.state; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; + +import java.util.Map; +import java.util.TreeMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class TsRollingArgumentEntryTest { + + private TsRollingArgumentEntry entry; + + private final long ts = System.currentTimeMillis(); + + @BeforeEach + void setUp() { + TreeMap values = new TreeMap<>(); + values.put(ts - 40, 10.0); + values.put(ts - 30, 12.0); + values.put(ts - 20, 17.0); + + entry = new TsRollingArgumentEntry(5, 30000L, values); + } + + @Test + void testArgumentEntryType() { + assertThat(entry.getType()).isEqualTo(ArgumentEntryType.TS_ROLLING); + } + + @Test + void testUpdateEntryWhenSingleValueEntryPassed() { + SingleValueArgumentEntry newEntry = new SingleValueArgumentEntry(ts - 10, new DoubleDataEntry("key", 23.0), 123L); + + assertThat(entry.updateEntry(newEntry)).isTrue(); + assertThat(entry.getTsRecords()).hasSize(4); + assertThat(entry.getTsRecords().get(ts - 10)).isEqualTo(23.0); + } + + @Test + void testUpdateEntryWhenRollingEntryPassed() { + TsRollingArgumentEntry newEntry = new TsRollingArgumentEntry(); + TreeMap values = new TreeMap<>(); + values.put(ts - 10, 7.0); + values.put(ts - 5, 1.0); + newEntry.setTsRecords(values); + + assertThat(entry.updateEntry(newEntry)).isTrue(); + assertThat(entry.getTsRecords()).hasSize(5); + assertThat(entry.getTsRecords()).isEqualTo(Map.of( + ts - 40, 10.0, + ts - 30, 12.0, + ts - 20, 17.0, + ts - 10, 7.0, + ts - 5, 1.0 + )); + } + + @Test + void testUpdateEntryWhenValueIsNotNumber() { + SingleValueArgumentEntry newEntry = new SingleValueArgumentEntry(ts - 10, new StringDataEntry("key", "string"), 123L); + + assertThat(entry.updateEntry(newEntry)).isTrue(); + assertThat(entry.getTsRecords().get(ts - 10)).isNaN(); + } + + @Test + void testUpdateEntryWhenOldTelemetry() { + TsRollingArgumentEntry newEntry = new TsRollingArgumentEntry(); + TreeMap values = new TreeMap<>(); + values.put(ts - 40000, 4.0);// will not be used for calculation + values.put(ts - 45000, 2.0);// will not be used for calculation + values.put(ts - 5, 0.0); + newEntry.setTsRecords(values); + + entry = new TsRollingArgumentEntry(3, 30000L); + assertThat(entry.updateEntry(newEntry)).isTrue(); + assertThat(entry.getTsRecords()).hasSize(1); + assertThat(entry.getTsRecords()).isEqualTo(Map.of( + ts - 5, 0.0 + )); + } + + @Test + void testPerformCalculationWhenArgumentsMoreThanLimit() { + TsRollingArgumentEntry newEntry = new TsRollingArgumentEntry(); + TreeMap values = new TreeMap<>(); + values.put(ts - 20, 1000.0);// will not be used + values.put(ts - 18, 0.0); + values.put(ts - 16, 0.0); + values.put(ts - 14, 0.0); + newEntry.setTsRecords(values); + + entry = new TsRollingArgumentEntry(3, 30000L); + assertThat(entry.updateEntry(newEntry)).isTrue(); + assertThat(entry.getTsRecords()).hasSize(3); + assertThat(entry.getTsRecords()).isEqualTo(Map.of( + ts - 18, 0.0, + ts - 16, 0.0, + ts - 14, 0.0 + )); + } + +} \ No newline at end of file diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java new file mode 100644 index 0000000000..50c80d08c7 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java @@ -0,0 +1,128 @@ +/** + * 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. + */ +package org.thingsboard.server.service.entitiy; + +import com.google.common.collect.Lists; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.IdBased; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.RelationsQueryFilter; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; +import org.thingsboard.server.common.msg.edqs.EdqsApiService; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.edqs.util.EdqsRocksDb; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.awaitility.Awaitility.await; + +@DaoSqlTest +@TestPropertySource(properties = { + "queue.edqs.sync.enabled=true", + "queue.edqs.api.supported=true", + "queue.edqs.api.auto_enable=true", + "queue.edqs.mode=local" +}) +public class EdqsEntityServiceTest extends EntityServiceTest { + + @Autowired + private EdqsApiService edqsApiService; + + @MockBean + private EdqsRocksDb edqsRocksDb; + + @Before + public void beforeEach() { + await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> edqsApiService.isEnabled()); + } + + // sql implementation has a bug with data duplication, edqs implementation returns correct value + @Override + @Test + public void testCountHierarchicalEntitiesByMultiRootQuery() throws InterruptedException { + List buildings = new ArrayList<>(); + List apartments = new ArrayList<>(); + Map> entityNameByTypeMap = new HashMap<>(); + Map childParentRelationMap = new HashMap<>(); + createMultiRootHierarchy(buildings, apartments, entityNameByTypeMap, childParentRelationMap); + + RelationsQueryFilter filter = new RelationsQueryFilter(); + filter.setMultiRoot(true); + filter.setMultiRootEntitiesType(EntityType.ASSET); + filter.setMultiRootEntityIds(buildings.stream().map(IdBased::getId).map(d -> d.getId().toString()).collect(Collectors.toSet())); + filter.setDirection(EntitySearchDirection.FROM); + + EntityCountQuery countQuery = new EntityCountQuery(filter); + countByQueryAndCheck(countQuery, 63); + + filter.setFilters(Collections.singletonList(new RelationEntityTypeFilter("AptToHeat", Collections.singletonList(EntityType.DEVICE)))); + countByQueryAndCheck(countQuery, 27); + + filter.setMultiRootEntitiesType(EntityType.ASSET); + filter.setMultiRootEntityIds(apartments.stream().map(IdBased::getId).map(d -> d.getId().toString()).collect(Collectors.toSet())); + filter.setDirection(EntitySearchDirection.TO); + filter.setFilters(Lists.newArrayList( + new RelationEntityTypeFilter("buildingToApt", Collections.singletonList(EntityType.ASSET)), + new RelationEntityTypeFilter("AptToEnergy", Collections.singletonList(EntityType.DEVICE)))); + countByQueryAndCheck(countQuery, 3); + + deviceService.deleteDevicesByTenantId(tenantId); + assetService.deleteAssetsByTenantId(tenantId); + } + + @Override + protected PageData findByQueryAndCheck(CustomerId customerId, EntityDataQuery query, long expectedResultSize) { + return await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> findByQuery(customerId, query), + result -> result.getTotalElements() == expectedResultSize); + } + + @Override + protected List findByQueryAndCheckTelemetry(EntityDataQuery query, EntityKeyType entityKeyType, String key, List expectedTelemetries) { + return await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> findEntitiesTelemetry(query, entityKeyType, key, expectedTelemetries), + loadedEntities -> loadedEntities.stream().map(entityData -> entityData.getLatest().get(entityKeyType).get(key).getValue()).toList().containsAll(expectedTelemetries)); + } + + @Override + protected long countByQueryAndCheck(EntityCountQuery countQuery, int expectedResult) { + return countByQueryAndCheck(new CustomerId(CustomerId.NULL_UUID), countQuery, expectedResult); + } + + @Override + protected long countByQueryAndCheck(CustomerId customerId, EntityCountQuery query, int expectedResult) { + return await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> countByQuery(customerId, query), + result -> result == expectedResult); + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java similarity index 80% rename from dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceTest.java rename to application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java index 0a0b5478e7..1e7f5384b5 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java @@ -13,22 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao.service; +package org.thingsboard.server.service.entitiy; import com.google.common.collect.Lists; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.RandomUtils; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.ResultSetExtractor; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.Asset; @@ -37,6 +40,7 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.IdBased; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; @@ -46,6 +50,8 @@ import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; +import org.thingsboard.server.common.data.objects.TelemetryEntityView; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.ApiUsageStateFilter; import org.thingsboard.server.common.data.query.AssetSearchQueryFilter; @@ -63,6 +69,7 @@ import org.thingsboard.server.common.data.query.EntityKey; import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.EntityListFilter; import org.thingsboard.server.common.data.query.EntityNameFilter; +import org.thingsboard.server.common.data.query.EntityViewTypeFilter; import org.thingsboard.server.common.data.query.FilterPredicateValue; import org.thingsboard.server.common.data.query.KeyFilter; import org.thingsboard.server.common.data.query.NumericFilterPredicate; @@ -75,17 +82,22 @@ import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.controller.AbstractControllerTest; import org.thingsboard.server.dao.alarm.AlarmService; +import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardDao; +import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.entityview.EntityViewDao; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.model.sqlts.ts.TsKvEntity; import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.sql.relation.RelationRepository; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.dao.usagerecord.ApiUsageStateService; @@ -105,13 +117,12 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.Assert.assertEquals; import static org.thingsboard.server.common.data.query.EntityKeyType.ATTRIBUTE; import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD; @Slf4j @DaoSqlTest -public class EntityServiceTest extends AbstractServiceTest { +public class EntityServiceTest extends AbstractControllerTest { static final int ENTITY_COUNT = 5; public static final String TEST_CUSTOMER_NAME = "Test"; @@ -119,6 +130,12 @@ public class EntityServiceTest extends AbstractServiceTest { @Autowired AssetService assetService; @Autowired + AssetProfileService assetProfileService; + @Autowired + DashboardService dashboardService; + @Autowired + EntityViewService entityViewService; + @Autowired UserService userService; @Autowired AttributesService attributesService; @@ -157,7 +174,7 @@ public class EntityServiceTest extends AbstractServiceTest { } @Test - public void testCountEntitiesByQuery() throws InterruptedException { + public void testCountEntitiesByQuery() { List devices = new ArrayList<>(); for (int i = 0; i < 97; i++) { Device device = new Device(); @@ -173,33 +190,26 @@ public class EntityServiceTest extends AbstractServiceTest { filter.setDeviceNameFilter(""); EntityCountQuery countQuery = new EntityCountQuery(filter); - - long count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(97, count); + countByQueryAndCheck(countQuery, 97); filter.setDeviceTypes(List.of("unknown")); - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(0, count); + countByQueryAndCheck(countQuery, 0); filter.setDeviceTypes(List.of("default")); filter.setDeviceNameFilter("Device1"); - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(11, count); + countByQueryAndCheck(countQuery, 11); EntityListFilter entityListFilter = new EntityListFilter(); entityListFilter.setEntityType(EntityType.DEVICE); entityListFilter.setEntityList(devices.stream().map(Device::getId).map(DeviceId::toString).collect(Collectors.toList())); countQuery = new EntityCountQuery(entityListFilter); - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(97, count); + countByQueryAndCheck(countQuery, 97); deviceService.deleteDevicesByTenantId(tenantId); - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(0, count); + countByQueryAndCheck(countQuery, 0); } - @Test public void testCountHierarchicalEntitiesByQuery() throws InterruptedException { List assets = new ArrayList<>(); @@ -211,19 +221,15 @@ public class EntityServiceTest extends AbstractServiceTest { filter.setDirection(EntitySearchDirection.FROM); EntityCountQuery countQuery = new EntityCountQuery(filter); - - long count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(31, count); //due to the loop relations in hierarchy, the TenantId included in total count (1*Tenant + 5*Asset + 5*5*Devices = 31) + countByQueryAndCheck(countQuery, 31); //due to the loop relations in hierarchy, the TenantId included in total count (1*Tenant + 5*Asset + 5*5*Devices = 31) filter.setFilters(Collections.singletonList(new RelationEntityTypeFilter("Contains", Collections.singletonList(EntityType.DEVICE)))); - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(25, count); + countByQueryAndCheck(countQuery, 25); filter.setRootEntity(devices.get(0).getId()); filter.setDirection(EntitySearchDirection.TO); filter.setFilters(Collections.singletonList(new RelationEntityTypeFilter("Manages", Collections.singletonList(EntityType.TENANT)))); - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(1, count); + countByQueryAndCheck(countQuery, 1); DeviceSearchQueryFilter filter2 = new DeviceSearchQueryFilter(); filter2.setRootEntity(tenantId); @@ -231,18 +237,14 @@ public class EntityServiceTest extends AbstractServiceTest { filter2.setRelationType("Contains"); countQuery = new EntityCountQuery(filter2); - - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(25, count); + countByQueryAndCheck(countQuery, 25); filter2.setDeviceTypes(Arrays.asList("default0", "default1")); - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(10, count); + countByQueryAndCheck(countQuery, 10); filter2.setRootEntity(devices.get(0).getId()); filter2.setDirection(EntitySearchDirection.TO); - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(0, count); + countByQueryAndCheck(countQuery, 0); AssetSearchQueryFilter filter3 = new AssetSearchQueryFilter(); filter3.setRootEntity(tenantId); @@ -250,18 +252,14 @@ public class EntityServiceTest extends AbstractServiceTest { filter3.setRelationType("Manages"); countQuery = new EntityCountQuery(filter3); - - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(5, count); + countByQueryAndCheck(countQuery, 5); filter3.setAssetTypes(Arrays.asList("type0", "type1")); - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(2, count); + countByQueryAndCheck(countQuery, 2); filter3.setRootEntity(devices.get(0).getId()); filter3.setDirection(EntitySearchDirection.TO); - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(0, count); + countByQueryAndCheck(countQuery, 0); } @Test @@ -278,11 +276,16 @@ public class EntityServiceTest extends AbstractServiceTest { EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, null, null); - PageData entityDataByQuery = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + PageData entityDataByQuery = findByQueryAndCheck(query, 5); List data = entityDataByQuery.getData(); Assert.assertEquals(data.size(), 5); data.forEach(entityData -> Assert.assertNotNull(entityData.getLatest().get(EntityKeyType.ENTITY_FIELD).get("phone"))); + countByQueryAndCheck(query, 5); + + // delete user + userService.deleteUser(tenantId, users.get(0)); + countByQueryAndCheck(query, 4); } private void createTestUserRelations(TenantId tenantId, List users) { @@ -312,30 +315,24 @@ public class EntityServiceTest extends AbstractServiceTest { filter.setEdgeNameFilter(""); EntityCountQuery countQuery = new EntityCountQuery(filter); - - long count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(97, count); + countByQueryAndCheck(countQuery, 97); filter.setEdgeTypes(List.of("unknown")); - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(0, count); + countByQueryAndCheck(countQuery, 0); filter.setEdgeTypes(List.of("default")); filter.setEdgeNameFilter("Edge1"); - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(11, count); + countByQueryAndCheck(countQuery, 11); EntityListFilter entityListFilter = new EntityListFilter(); entityListFilter.setEntityType(EntityType.EDGE); entityListFilter.setEntityList(edges.stream().map(Edge::getId).map(EdgeId::toString).collect(Collectors.toList())); countQuery = new EntityCountQuery(entityListFilter); - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(97, count); + countByQueryAndCheck(countQuery, 97); edgeService.deleteEdgesByTenantId(tenantId); - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(0, count); + countByQueryAndCheck(countQuery, 0); } @Test @@ -360,13 +357,10 @@ public class EntityServiceTest extends AbstractServiceTest { filter.setRelationType("Manages"); EntityCountQuery countQuery = new EntityCountQuery(filter); - - long count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(5, count); + countByQueryAndCheck(countQuery, 5); filter.setEdgeTypes(Arrays.asList("type0", "type1")); - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(2, count); + countByQueryAndCheck(countQuery, 2); } private Edge createEdge(int i, String type) { @@ -423,19 +417,10 @@ public class EntityServiceTest extends AbstractServiceTest { List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, null); - PageData data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - List loadedEntities = new ArrayList<>(data.getData()); - while (data.hasNext()) { - query = query.next(); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - loadedEntities.addAll(data.getData()); - } - Assert.assertEquals(25, loadedEntities.size()); - List loadedTemperatures = loadedEntities.stream().map(entityData -> - entityData.getLatest().get(EntityKeyType.ATTRIBUTE).get("temperature").getValue()).collect(Collectors.toList()); List deviceTemperatures = temperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); - Assert.assertEquals(deviceTemperatures, loadedTemperatures); + + EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, null); + findByQueryAndCheckTelemetry(query, EntityKeyType.ATTRIBUTE, "temperature", deviceTemperatures); pageLink = new EntityDataPageLink(10, 0, null, sortOrder); KeyFilter highTemperatureFilter = new KeyFilter(); @@ -446,23 +431,10 @@ public class EntityServiceTest extends AbstractServiceTest { highTemperatureFilter.setPredicate(predicate); List keyFilters = Collections.singletonList(highTemperatureFilter); - query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); - - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - - loadedEntities = new ArrayList<>(data.getData()); - while (data.hasNext()) { - query = query.next(); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - loadedEntities.addAll(data.getData()); - } - Assert.assertEquals(highTemperatures.size(), loadedEntities.size()); - - List loadedHighTemperatures = loadedEntities.stream().map(entityData -> - entityData.getLatest().get(EntityKeyType.ATTRIBUTE).get("temperature").getValue()).collect(Collectors.toList()); List deviceHighTemperatures = highTemperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); - Assert.assertEquals(deviceHighTemperatures, loadedHighTemperatures); + query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); + findByQueryAndCheckTelemetry(query, EntityKeyType.ATTRIBUTE, "temperature", deviceHighTemperatures); deviceService.deleteDevicesByTenantId(tenantId); } @@ -482,13 +454,10 @@ public class EntityServiceTest extends AbstractServiceTest { filter.setDirection(EntitySearchDirection.FROM); EntityCountQuery countQuery = new EntityCountQuery(filter); - - long count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(63, count); + countByQueryAndCheck(countQuery, 63); filter.setFilters(Collections.singletonList(new RelationEntityTypeFilter("AptToHeat", Collections.singletonList(EntityType.DEVICE)))); - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(27, count); + countByQueryAndCheck(countQuery, 27); filter.setMultiRootEntitiesType(EntityType.ASSET); filter.setMultiRootEntityIds(apartments.stream().map(IdBased::getId).map(d -> d.getId().toString()).collect(Collectors.toSet())); @@ -496,13 +465,10 @@ public class EntityServiceTest extends AbstractServiceTest { filter.setFilters(Lists.newArrayList( new RelationEntityTypeFilter("buildingToApt", Collections.singletonList(EntityType.ASSET)), new RelationEntityTypeFilter("AptToEnergy", Collections.singletonList(EntityType.DEVICE)))); - - count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery); - Assert.assertEquals(9, count); + countByQueryAndCheck(countQuery, 9); deviceService.deleteDevicesByTenantId(tenantId); assetService.deleteAssetsByTenantId(tenantId); - } @Test @@ -538,15 +504,6 @@ public class EntityServiceTest extends AbstractServiceTest { onlineStatusFilter.setPredicate(predicate); List keyFilters = Collections.singletonList(onlineStatusFilter); - EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); - PageData data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - List loadedEntities = new ArrayList<>(data.getData()); - while (data.hasNext()) { - query = query.next(); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - loadedEntities.addAll(data.getData()); - } - long expectedEntitiesCnt = entityNameByTypeMap.entrySet() .stream() .filter(e -> !e.getKey().equals("building")) @@ -554,6 +511,14 @@ public class EntityServiceTest extends AbstractServiceTest { .map(Map.Entry::getValue) .filter(e -> StringUtils.endsWith(e, "_1")) .count(); + EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); + PageData data = findByQueryAndCheck(query, expectedEntitiesCnt); + List loadedEntities = new ArrayList<>(data.getData()); + while (data.hasNext()) { + query = query.next(); + data = findByQuery(query); + loadedEntities.addAll(data.getData()); + } Assert.assertEquals(expectedEntitiesCnt, loadedEntities.size()); Map actualRelations = new HashMap<>(); @@ -602,20 +567,12 @@ public class EntityServiceTest extends AbstractServiceTest { List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + List deviceTemperatures = temperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); + EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, null); - PageData data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - List loadedEntities = new ArrayList<>(data.getData()); - while (data.hasNext()) { - query = query.next(); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - loadedEntities.addAll(data.getData()); - } - Assert.assertEquals(25, loadedEntities.size()); + List loadedEntities = findByQueryAndCheckTelemetry(query, EntityKeyType.ATTRIBUTE, "temperature", deviceTemperatures); + loadedEntities.forEach(entity -> Assert.assertTrue(devices.stream().map(Device::getId).collect(Collectors.toSet()).contains(entity.getEntityId()))); - List loadedTemperatures = loadedEntities.stream().map(entityData -> - entityData.getLatest().get(EntityKeyType.ATTRIBUTE).get("temperature").getValue()).collect(Collectors.toList()); - List deviceTemperatures = temperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); - Assert.assertEquals(deviceTemperatures, loadedTemperatures); pageLink = new EntityDataPageLink(10, 0, null, sortOrder); KeyFilter highTemperatureFilter = new KeyFilter(); @@ -628,21 +585,8 @@ public class EntityServiceTest extends AbstractServiceTest { query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - - loadedEntities = new ArrayList<>(data.getData()); - while (data.hasNext()) { - query = query.next(); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - loadedEntities.addAll(data.getData()); - } - Assert.assertEquals(highTemperatures.size(), loadedEntities.size()); - - List loadedHighTemperatures = loadedEntities.stream().map(entityData -> - entityData.getLatest().get(EntityKeyType.ATTRIBUTE).get("temperature").getValue()).collect(Collectors.toList()); List deviceHighTemperatures = highTemperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); - - Assert.assertEquals(deviceHighTemperatures, loadedHighTemperatures); + findByQueryAndCheckTelemetry(query, EntityKeyType.ATTRIBUTE, "temperature", deviceHighTemperatures); deviceService.deleteDevicesByTenantId(tenantId); } @@ -676,18 +620,9 @@ public class EntityServiceTest extends AbstractServiceTest { List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.ATTRIBUTE, "consumption")); EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, null); - PageData data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - List loadedEntities = new ArrayList<>(data.getData()); - while (data.hasNext()) { - query = query.next(); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - loadedEntities.addAll(data.getData()); - } - Assert.assertEquals(5, loadedEntities.size()); - List loadedTemperatures = loadedEntities.stream().map(entityData -> - entityData.getLatest().get(EntityKeyType.ATTRIBUTE).get("consumption").getValue()).collect(Collectors.toList()); + List deviceTemperatures = consumptions.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); - Assert.assertEquals(deviceTemperatures, loadedTemperatures); + findByQueryAndCheckTelemetry(query, EntityKeyType.ATTRIBUTE, "consumption", deviceTemperatures); pageLink = new EntityDataPageLink(10, 0, null, sortOrder); KeyFilter highTemperatureFilter = new KeyFilter(); @@ -700,21 +635,8 @@ public class EntityServiceTest extends AbstractServiceTest { query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - - loadedEntities = new ArrayList<>(data.getData()); - while (data.hasNext()) { - query = query.next(); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - loadedEntities.addAll(data.getData()); - } - Assert.assertEquals(highConsumptions.size(), loadedEntities.size()); - - List loadedHighTemperatures = loadedEntities.stream().map(entityData -> - entityData.getLatest().get(EntityKeyType.ATTRIBUTE).get("consumption").getValue()).collect(Collectors.toList()); List deviceHighTemperatures = highConsumptions.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); - - Assert.assertEquals(deviceHighTemperatures, loadedHighTemperatures); + findByQueryAndCheckTelemetry(query, EntityKeyType.ATTRIBUTE, "consumption", deviceHighTemperatures); deviceService.deleteDevicesByTenantId(tenantId); } @@ -896,9 +818,7 @@ public class EntityServiceTest extends AbstractServiceTest { List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, null, null); - PageData data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - - Assert.assertEquals(97, data.getTotalElements()); + PageData data = findByQueryAndCheck(query, 97); Assert.assertEquals(10, data.getTotalPages()); Assert.assertTrue(data.hasNext()); Assert.assertEquals(10, data.getData().size()); @@ -906,7 +826,7 @@ public class EntityServiceTest extends AbstractServiceTest { List loadedEntities = new ArrayList<>(data.getData()); while (data.hasNext()) { query = query.next(); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + data = findByQuery(query); loadedEntities.addAll(data.getData()); } Assert.assertEquals(97, loadedEntities.size()); @@ -931,7 +851,7 @@ public class EntityServiceTest extends AbstractServiceTest { pageLink = new EntityDataPageLink(10, 0, "device1", sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, null, null); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + data = findByQuery(query); Assert.assertEquals(11, data.getTotalElements()); Assert.assertEquals("Device19", data.getData().get(0).getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()); @@ -945,11 +865,12 @@ public class EntityServiceTest extends AbstractServiceTest { devices.get(1).setLabel(null); devices.forEach(deviceService::saveDevice); + // FIXME (for Dasha, plz investigate): + // this and other tests below submit an empty value to a KEY FILTER, this is not "search text". + // why are we supposed to ignore it and return all devices? maybe it's a bug? String searchQuery = ""; EntityDataQuery query = createDeviceSearchQuery("label", StringOperation.EQUAL, searchQuery); - - PageData result = searchEntities(query); - assertEquals(devices.size(), result.getTotalElements()); + findByQueryAndCheck(query, devices.size()); } @Test @@ -961,9 +882,7 @@ public class EntityServiceTest extends AbstractServiceTest { String searchQuery = devices.get(2).getLabel(); EntityDataQuery query = createDeviceSearchQuery("label", StringOperation.NOT_EQUAL, searchQuery); - - PageData result = searchEntities(query); - assertEquals(devices.size() - 1, result.getTotalElements()); + findByQueryAndCheck(query, devices.size() - 1); } @Test @@ -976,8 +895,7 @@ public class EntityServiceTest extends AbstractServiceTest { String searchQuery = ""; EntityDataQuery query = createDeviceSearchQuery("label", StringOperation.NOT_EQUAL, searchQuery); - PageData result = searchEntities(query); - assertEquals(devices.size(), result.getTotalElements()); + findByQueryAndCheck(query, devices.size()); } @Test @@ -989,9 +907,7 @@ public class EntityServiceTest extends AbstractServiceTest { String searchQuery = ""; EntityDataQuery query = createDeviceSearchQuery("label", StringOperation.STARTS_WITH, searchQuery); - - PageData result = searchEntities(query); - assertEquals(devices.size(), result.getTotalElements()); + findByQueryAndCheck(query, devices.size()); } @Test @@ -1003,9 +919,7 @@ public class EntityServiceTest extends AbstractServiceTest { String searchQuery = ""; EntityDataQuery query = createDeviceSearchQuery("label", StringOperation.ENDS_WITH, searchQuery); - - PageData result = searchEntities(query); - assertEquals(devices.size(), result.getTotalElements()); + findByQueryAndCheck(query, devices.size()); } @Test @@ -1017,9 +931,7 @@ public class EntityServiceTest extends AbstractServiceTest { String searchQuery = ""; EntityDataQuery query = createDeviceSearchQuery("label", StringOperation.CONTAINS, searchQuery); - - PageData result = searchEntities(query); - assertEquals(devices.size(), result.getTotalElements()); + findByQueryAndCheck(query, devices.size()); } @Test @@ -1031,9 +943,7 @@ public class EntityServiceTest extends AbstractServiceTest { String searchQuery = "label-"; EntityDataQuery query = createDeviceSearchQuery("label", StringOperation.NOT_CONTAINS, searchQuery); - - PageData result = searchEntities(query); - assertEquals(2, result.getTotalElements()); + findByQueryAndCheck(query, 2); } @Test @@ -1045,9 +955,7 @@ public class EntityServiceTest extends AbstractServiceTest { String searchQuery = ""; EntityDataQuery query = createDeviceSearchQuery("label", StringOperation.NOT_CONTAINS, searchQuery); - - PageData result = searchEntities(query); - assertEquals(devices.size(), result.getTotalElements()); + findByQueryAndCheck(query, devices.size()); } @Test @@ -1071,34 +979,27 @@ public class EntityServiceTest extends AbstractServiceTest { EntityDataPageLink pageLink = new EntityDataPageLink(1000, 0, null, null); EntityDataQuery query = new EntityDataQuery(deviceTypeFilter, pageLink, null, null, null); - - PageData result = searchEntities(query); - assertEquals(devices.size(), result.getTotalElements()); + findByQueryAndCheck(query, devices.size()); deviceTypeFilter.setEntityNameFilter("Device%"); - - result = searchEntities(query); - assertEquals(devices.size(), result.getTotalElements()); + findByQueryAndCheck(query, devices.size()); deviceTypeFilter.setEntityNameFilter("%Device%"); - - result = searchEntities(query); - assertEquals(devices.size(), result.getTotalElements()); + findByQueryAndCheck(query, devices.size()); deviceTypeFilter.setEntityNameFilter("%Device"); - - result = searchEntities(query); - assertEquals(0, result.getTotalElements()); + findByQueryAndCheck(query, 0); } @Test public void testFindEntityDataByQuery_filter_entity_name_ends_with() { List devices = new ArrayList<>(); + String suffixes = RandomStringUtils.randomAlphanumeric(5); for (int i = 0; i < 10; i++) { Device device = new Device(); device.setTenantId(tenantId); - device.setName("Device " + i + " test"); + device.setName("Device " + i + suffixes); device.setType("default"); devices.add(device); } @@ -1107,29 +1008,21 @@ public class EntityServiceTest extends AbstractServiceTest { EntityNameFilter deviceTypeFilter = new EntityNameFilter(); deviceTypeFilter.setEntityType(EntityType.DEVICE); - deviceTypeFilter.setEntityNameFilter("%test"); + deviceTypeFilter.setEntityNameFilter("%" + suffixes); EntityDataPageLink pageLink = new EntityDataPageLink(1000, 0, null, null); EntityDataQuery query = new EntityDataQuery(deviceTypeFilter, pageLink, null, null, null); + findByQueryAndCheck(query, devices.size()); - PageData result = searchEntities(query); - assertEquals(devices.size(), result.getTotalElements()); + deviceTypeFilter.setEntityNameFilter("%" + suffixes + "%"); + findByQueryAndCheck(query, devices.size()); - deviceTypeFilter.setEntityNameFilter("%test%"); - - result = searchEntities(query); - assertEquals(devices.size(), result.getTotalElements()); - - deviceTypeFilter.setEntityNameFilter("test%"); - - result = searchEntities(query); - assertEquals(0, result.getTotalElements()); + deviceTypeFilter.setEntityNameFilter(suffixes + "%"); + findByQueryAndCheck(query, 0); - deviceTypeFilter.setEntityNameFilter("test"); - - result = searchEntities(query); - assertEquals(0, result.getTotalElements()); + deviceTypeFilter.setEntityNameFilter(suffixes); + findByQueryAndCheck(query, 0); } @Test @@ -1153,19 +1046,13 @@ public class EntityServiceTest extends AbstractServiceTest { EntityDataPageLink pageLink = new EntityDataPageLink(1000, 0, null, null); EntityDataQuery query = new EntityDataQuery(deviceTypeFilter, pageLink, null, null, null); - - PageData result = searchEntities(query); - assertEquals(devices.size(), result.getTotalElements()); + findByQueryAndCheck(query, devices.size()); deviceTypeFilter.setEntityNameFilter("test%"); - - result = searchEntities(query); - assertEquals(0, result.getTotalElements()); + findByQueryAndCheck(query, 0); deviceTypeFilter.setEntityNameFilter("%test"); - - result = searchEntities(query); - assertEquals(0, result.getTotalElements()); + findByQueryAndCheck(query, 0); } @Test @@ -1189,24 +1076,16 @@ public class EntityServiceTest extends AbstractServiceTest { EntityDataPageLink pageLink = new EntityDataPageLink(1000, 0, null, null); EntityDataQuery query = new EntityDataQuery(deviceTypeFilter, pageLink, null, null, null); - - PageData result = searchEntities(query); - assertEquals(devices.size(), result.getTotalElements()); + findByQueryAndCheck(query, devices.size()); deviceTypeFilter.setDeviceNameFilter("Device%"); - - result = searchEntities(query); - assertEquals(devices.size(), result.getTotalElements()); + findByQueryAndCheck(query, devices.size()); deviceTypeFilter.setDeviceNameFilter("%Device%"); - - result = searchEntities(query); - assertEquals(devices.size(), result.getTotalElements()); + findByQueryAndCheck(query, devices.size()); deviceTypeFilter.setDeviceNameFilter("%Device"); - - result = searchEntities(query); - assertEquals(0, result.getTotalElements()); + findByQueryAndCheck(query, 0); } @Test @@ -1232,31 +1111,12 @@ public class EntityServiceTest extends AbstractServiceTest { EntityDataQuery query = new EntityDataQuery(singleEntityFilter, pageLink, entityFields, null, null); - PageData result = searchEntities(query); - assertEquals(1, result.getTotalElements()); + PageData result = findByQueryAndCheck(query, 1); String deviceName = result.getData().get(0).getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue(); assertThat(deviceName).isEqualTo(devices.get(0).getName()); } - @Test - public void testFindEntitiesByApiUsageStateFilter() { - apiUsageStateService.createDefaultApiUsageState(tenantId, customerId); - ApiUsageStateFilter apiUsageStateFilter = new ApiUsageStateFilter(); - apiUsageStateFilter.setCustomerId(customerId); - - List entityFields = List.of( - new EntityKey(EntityKeyType.ENTITY_FIELD, "name") - ); - - EntityDataPageLink pageLink = new EntityDataPageLink(1000, 0, null, null); - EntityDataQuery query = new EntityDataQuery(apiUsageStateFilter, pageLink, entityFields, null, null); - PageData result = searchEntities(query); - assertEquals(1, result.getTotalElements()); - String name = result.getData().get(0).getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue(); - assertThat(name).isEqualTo(TEST_CUSTOMER_NAME); - } - @Test public void testFindEntitiesByRelationEntityTypeFilter() { Customer customer = new Customer(); @@ -1312,11 +1172,8 @@ public class EntityServiceTest extends AbstractServiceTest { filter.setRootEntity(asset.getId()); EntityDataQuery query = new EntityDataQuery(filter, pageLink, Collections.emptyList(), Collections.emptyList(), keyFiltersEqualString); - PageData relationsResult = entityService.findEntityDataByQuery(tenantId, customer.getId(), query); - long relationsResultCnt = entityService.countEntitiesByQuery(tenantId, customer.getId(), query); - - Assert.assertEquals(relationsCnt, relationsResult.getData().size()); - Assert.assertEquals(relationsCnt, relationsResultCnt); + findByQueryAndCheck(customer.getId(), query, relationsCnt); + countByQueryAndCheck(customer.getId(), query, relationsCnt); } } @@ -1341,24 +1198,16 @@ public class EntityServiceTest extends AbstractServiceTest { EntityDataPageLink pageLink = new EntityDataPageLink(1000, 0, null, null); EntityDataQuery query = new EntityDataQuery(deviceTypeFilter, pageLink, null, null, null); - - PageData result = searchEntities(query); - assertEquals(devices.size(), result.getTotalElements()); + findByQueryAndCheck(query, devices.size()); deviceTypeFilter.setDeviceNameFilter("%test%"); - - result = searchEntities(query); - assertEquals(devices.size(), result.getTotalElements()); + findByQueryAndCheck(query, devices.size()); deviceTypeFilter.setDeviceNameFilter("test%"); - - result = searchEntities(query); - assertEquals(0, result.getTotalElements()); + findByQueryAndCheck(query, 0); deviceTypeFilter.setDeviceNameFilter("test"); - - result = searchEntities(query); - assertEquals(0, result.getTotalElements()); + findByQueryAndCheck(query, 0); } @Test @@ -1382,19 +1231,13 @@ public class EntityServiceTest extends AbstractServiceTest { EntityDataPageLink pageLink = new EntityDataPageLink(1000, 0, null, null); EntityDataQuery query = new EntityDataQuery(deviceTypeFilter, pageLink, null, null, null); - - PageData result = searchEntities(query); - assertEquals(devices.size(), result.getTotalElements()); + findByQueryAndCheck(query, devices.size()); deviceTypeFilter.setDeviceNameFilter("test%"); - - result = searchEntities(query); - assertEquals(0, result.getTotalElements()); + findByQueryAndCheck(query, 0); deviceTypeFilter.setDeviceNameFilter("%test"); - - result = searchEntities(query); - assertEquals(0, result.getTotalElements()); + findByQueryAndCheck(query, 0); } @Test @@ -1418,24 +1261,16 @@ public class EntityServiceTest extends AbstractServiceTest { EntityDataPageLink pageLink = new EntityDataPageLink(1000, 0, null, null); EntityDataQuery query = new EntityDataQuery(assetTypeFilter, pageLink, null, null, null); - - PageData result = searchEntities(query); - assertEquals(assets.size(), result.getTotalElements()); + findByQueryAndCheck(query, assets.size()); assetTypeFilter.setAssetNameFilter("Asset%"); - - result = searchEntities(query); - assertEquals(assets.size(), result.getTotalElements()); + findByQueryAndCheck(query, assets.size()); assetTypeFilter.setAssetNameFilter("%Asset%"); - - result = searchEntities(query); - assertEquals(assets.size(), result.getTotalElements()); + findByQueryAndCheck(query, assets.size()); assetTypeFilter.setAssetNameFilter("%Asset"); - - result = searchEntities(query); - assertEquals(0, result.getTotalElements()); + findByQueryAndCheck(query, 0); } @Test @@ -1459,24 +1294,16 @@ public class EntityServiceTest extends AbstractServiceTest { EntityDataPageLink pageLink = new EntityDataPageLink(1000, 0, null, null); EntityDataQuery query = new EntityDataQuery(assetTypeFilter, pageLink, null, null, null); - - PageData result = searchEntities(query); - assertEquals(assets.size(), result.getTotalElements()); + findByQueryAndCheck(query, assets.size()); assetTypeFilter.setAssetNameFilter("%test%"); - - result = searchEntities(query); - assertEquals(assets.size(), result.getTotalElements()); + findByQueryAndCheck(query, assets.size()); assetTypeFilter.setAssetNameFilter("test%"); - - result = searchEntities(query); - assertEquals(0, result.getTotalElements()); + findByQueryAndCheck(query, 0); assetTypeFilter.setAssetNameFilter("test"); - - result = searchEntities(query); - assertEquals(0, result.getTotalElements()); + findByQueryAndCheck(query, 0); } @Test @@ -1488,6 +1315,7 @@ public class EntityServiceTest extends AbstractServiceTest { asset.setTenantId(tenantId); asset.setName("Asset test" + i); asset.setType("default"); + asset.setAssetProfileId(assetProfileService.findDefaultAssetProfile(tenantId).getId()); assets.add(asset); } @@ -1500,25 +1328,105 @@ public class EntityServiceTest extends AbstractServiceTest { EntityDataPageLink pageLink = new EntityDataPageLink(1000, 0, null, null); EntityDataQuery query = new EntityDataQuery(assetTypeFilter, pageLink, null, null, null); - - PageData result = searchEntities(query); - assertEquals(assets.size(), result.getTotalElements()); + findByQueryAndCheck(query, assets.size()); assetTypeFilter.setAssetNameFilter("test%"); - - result = searchEntities(query); - assertEquals(0, result.getTotalElements()); + findByQueryAndCheck(query, 0); assetTypeFilter.setAssetNameFilter("%test"); + findByQueryAndCheck(query, 0); + } - result = searchEntities(query); - assertEquals(0, result.getTotalElements()); + @Test + public void testFindEntitiesBySingleEntityFilter_customer() { + List customerDevices = new ArrayList<>(); + List tenantDevices = new ArrayList<>(); + + for (int i = 0; i < 3; i++) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setCustomerId(customerId); + device.setName("Device test" + i); + device.setType("default"); + Device saved = deviceService.saveDevice(device); + customerDevices.add(saved); + } + + for (int i = 0; i < 3; i++) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setName("Tenant test device" + i); + device.setType("default"); + tenantDevices.add(deviceService.saveDevice(device)); + } + + SingleEntityFilter singleEntityFilter = new SingleEntityFilter(); + singleEntityFilter.setSingleEntity(customerDevices.get(0).getId()); + List entityFields = List.of( + new EntityKey(EntityKeyType.ENTITY_FIELD, "name") + ); + EntityDataPageLink pageLink = new EntityDataPageLink(1000, 0, null, null); + EntityDataQuery query = new EntityDataQuery(singleEntityFilter, pageLink, entityFields, null, null); + + PageData result = findByQueryAndCheck(query, 1); + String deviceName = result.getData().get(0).getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue(); + assertThat(deviceName).isEqualTo(customerDevices.get(0).getName()); + + // find by customer user with generic permission + PageData customerResults = findByQueryAndCheck(customerId, query, 1); + + String cutomerDeviceName = customerResults.getData().get(0).getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue(); + assertThat(cutomerDeviceName).isEqualTo(customerDevices.get(0).getName()); + + // try to find tenant device by customer user + SingleEntityFilter tenantDeviceFilter = new SingleEntityFilter(); + tenantDeviceFilter.setSingleEntity(tenantDevices.get(0).getId()); + EntityDataQuery customerQuery2 = new EntityDataQuery(tenantDeviceFilter, pageLink, entityFields, null, null); + findByQueryAndCheck(customerId, customerQuery2, 0); } - private PageData searchEntities(EntityDataQuery query) { - return entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + private List getResultDeviceIds(PageData result) { + return result.getData().stream().map(entityData -> (DeviceId) entityData.getEntityId()).collect(Collectors.toList()); } + private Device createDevice(CustomerId customerId) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setCustomerId(customerId); + device.setName("Device test " + RandomStringUtils.randomAlphabetic(5)); + device.setType("default"); + return device; + } + + @Test + public void testFindEntitiesByApiUsageStateFilter() { + ApiUsageStateFilter apiUsageStateFilter = new ApiUsageStateFilter(); + + List entityFields = List.of( + new EntityKey(EntityKeyType.ENTITY_FIELD, "name") + ); + + EntityDataPageLink pageLink = new EntityDataPageLink(1000, 0, null, null); + EntityDataQuery query = new EntityDataQuery(apiUsageStateFilter, pageLink, entityFields, null, null); + PageData result = findByQueryAndCheck(query, 1); + String name = result.getData().get(0).getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue(); + assertThat(name).isEqualTo(TEST_TENANT_NAME); + + // find by customer user with generic permissions + apiUsageStateService.createDefaultApiUsageState(tenantId, customerId); + PageData customerResult = findByQueryAndCheck(customerId, query, 1); + + String customerResultName = customerResult.getData().get(0).getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue(); + assertThat(customerResultName).isEqualTo(TEST_CUSTOMER_NAME); + + // find by tenant user with customerId filter + apiUsageStateFilter.setCustomerId(customerId); + PageData tenantResult = findByQueryAndCheck(query, 1); + String tenantResultName = tenantResult.getData().get(0).getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue(); + assertThat(tenantResultName).isEqualTo(TEST_CUSTOMER_NAME); + } + + private EntityDataQuery createDeviceSearchQuery(String deviceField, StringOperation operation, String searchQuery) { DeviceTypeFilter deviceTypeFilter = new DeviceTypeFilter(); deviceTypeFilter.setDeviceTypes(List.of("default")); @@ -1597,45 +1505,15 @@ public class EntityServiceTest extends AbstractServiceTest { for (EntityKeyType currentAttributeKeyType : attributesEntityTypes) { List latestValues = Collections.singletonList(new EntityKey(currentAttributeKeyType, "temperature")); EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, null); - PageData data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - List loadedEntities = new ArrayList<>(data.getData()); - while (data.hasNext()) { - query = query.next(); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - loadedEntities.addAll(data.getData()); - } - Assert.assertEquals(67, loadedEntities.size()); - List loadedTemperatures = new ArrayList<>(); - for (Device device : devices) { - loadedTemperatures.add(loadedEntities.stream().filter(entityData -> entityData.getEntityId().equals(device.getId())).findFirst().orElse(null) - .getLatest().get(currentAttributeKeyType).get("temperature").getValue()); - } - List deviceTemperatures = temperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); - Assert.assertEquals(deviceTemperatures, loadedTemperatures); + List deviceTemperatures = temperatures.stream().map(aLong -> Long.toString(aLong)).toList(); + findByQueryAndCheckTelemetry(query, currentAttributeKeyType, "temperature", deviceTemperatures); pageLink = new EntityDataPageLink(10, 0, null, sortOrder); KeyFilter highTemperatureFilter = createNumericKeyFilter("temperature", currentAttributeKeyType, NumericFilterPredicate.NumericOperation.GREATER, 45); List keyFiltersHighTemperature = Collections.singletonList(highTemperatureFilter); query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFiltersHighTemperature); - - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - - loadedEntities = new ArrayList<>(data.getData()); - - while (data.hasNext()) { - query = query.next(); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - loadedEntities.addAll(data.getData()); - } - Assert.assertEquals(highTemperatures.size(), loadedEntities.size()); - - List loadedHighTemperatures = loadedEntities.stream().map(entityData -> - entityData.getLatest().get(currentAttributeKeyType).get("temperature").getValue()).collect(Collectors.toList()); - List deviceHighTemperatures = highTemperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); - - Assert.assertEquals(deviceHighTemperatures, loadedHighTemperatures); - + findByQueryAndCheckTelemetry(query, currentAttributeKeyType, "temperature", highTemperatures.stream().map(Object::toString).toList()); } deviceService.deleteDevicesByTenantId(tenantId); } @@ -1715,89 +1593,48 @@ public class EntityServiceTest extends AbstractServiceTest { List keyFiltersNotEqualTemperature = Collections.singletonList(notEqualTemperatureFilter); //Greater Operation + List deviceTemperatures = greaterTemperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); EntityDataPageLink pageLink = new EntityDataPageLink(100, 0, null, sortOrder); EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFiltersGreaterTemperature); - PageData data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - List loadedEntities = getLoadedEntities(data, query); - Assert.assertEquals(greaterTemperatures.size(), loadedEntities.size()); - List loadedTemperatures = loadedEntities.stream().map(entityData -> - entityData.getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("temperature").getValue()).collect(Collectors.toList()); - List deviceTemperatures = greaterTemperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); - - Assert.assertEquals(deviceTemperatures, loadedTemperatures); + findByQueryAndCheckTelemetry(query, EntityKeyType.CLIENT_ATTRIBUTE, "temperature", deviceTemperatures); //Greater or equal Operation + deviceTemperatures = greaterOrEqualTemperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); pageLink = new EntityDataPageLink(100, 0, null, sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFiltersGreaterOrEqualTemperature); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - loadedEntities = getLoadedEntities(data, query); - Assert.assertEquals(greaterOrEqualTemperatures.size(), loadedEntities.size()); - - loadedTemperatures = loadedEntities.stream().map(entityData -> - entityData.getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("temperature").getValue()).collect(Collectors.toList()); - deviceTemperatures = greaterOrEqualTemperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); - Assert.assertEquals(deviceTemperatures, loadedTemperatures); + findByQueryAndCheckTelemetry(query, EntityKeyType.CLIENT_ATTRIBUTE, "temperature", deviceTemperatures); //Less Operation + deviceTemperatures = lessTemperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); pageLink = new EntityDataPageLink(100, 0, null, sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFiltersLessTemperature); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - loadedEntities = getLoadedEntities(data, query); - Assert.assertEquals(lessTemperatures.size(), loadedEntities.size()); - - loadedTemperatures = loadedEntities.stream().map(entityData -> - entityData.getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("temperature").getValue()).collect(Collectors.toList()); - deviceTemperatures = lessTemperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); - - Assert.assertEquals(deviceTemperatures, loadedTemperatures); + findByQueryAndCheckTelemetry(query, EntityKeyType.CLIENT_ATTRIBUTE, "temperature", deviceTemperatures); //Less or equal Operation + deviceTemperatures = lessOrEqualTemperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); pageLink = new EntityDataPageLink(100, 0, null, sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFiltersLessOrEqualTemperature); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - loadedEntities = getLoadedEntities(data, query); - Assert.assertEquals(lessOrEqualTemperatures.size(), loadedEntities.size()); - - loadedTemperatures = loadedEntities.stream().map(entityData -> - entityData.getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("temperature").getValue()).collect(Collectors.toList()); - deviceTemperatures = lessOrEqualTemperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); - - Assert.assertEquals(deviceTemperatures, loadedTemperatures); + findByQueryAndCheckTelemetry(query, EntityKeyType.CLIENT_ATTRIBUTE, "temperature", deviceTemperatures); //Equal Operation + deviceTemperatures = equalTemperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); pageLink = new EntityDataPageLink(100, 0, null, sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFiltersEqualTemperature); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - loadedEntities = getLoadedEntities(data, query); - Assert.assertEquals(equalTemperatures.size(), loadedEntities.size()); - - loadedTemperatures = loadedEntities.stream().map(entityData -> - entityData.getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("temperature").getValue()).collect(Collectors.toList()); - deviceTemperatures = equalTemperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); - - Assert.assertEquals(deviceTemperatures, loadedTemperatures); + findByQueryAndCheckTelemetry(query, EntityKeyType.CLIENT_ATTRIBUTE, "temperature", deviceTemperatures); //Not equal Operation + deviceTemperatures = notEqualTemperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); pageLink = new EntityDataPageLink(100, 0, null, sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFiltersNotEqualTemperature); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - loadedEntities = getLoadedEntities(data, query); - Assert.assertEquals(notEqualTemperatures.size(), loadedEntities.size()); - - loadedTemperatures = loadedEntities.stream().map(entityData -> - entityData.getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("temperature").getValue()).collect(Collectors.toList()); - deviceTemperatures = notEqualTemperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); - - Assert.assertEquals(deviceTemperatures, loadedTemperatures); - + findByQueryAndCheckTelemetry(query, EntityKeyType.CLIENT_ATTRIBUTE, "temperature", deviceTemperatures); deviceService.deleteDevicesByTenantId(tenantId); } @@ -1824,7 +1661,7 @@ public class EntityServiceTest extends AbstractServiceTest { } } - List> timeseriesFutures = new ArrayList<>(); + List> timeseriesFutures = new ArrayList<>(); for (int i = 0; i < devices.size(); i++) { Device device = devices.get(i); timeseriesFutures.add(saveLongTimeseries(device.getId(), "temperature", temperatures.get(i))); @@ -1842,24 +1679,10 @@ public class EntityServiceTest extends AbstractServiceTest { List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "temperature")); - EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, null); - PageData data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - - List loadedEntities = new ArrayList<>(data.getData()); - while (data.hasNext()) { - query = query.next(); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - loadedEntities.addAll(data.getData()); - } - Assert.assertEquals(67, loadedEntities.size()); - List loadedTemperatures = new ArrayList<>(); - for (Device device : devices) { - loadedTemperatures.add(loadedEntities.stream().filter(entityData -> entityData.getEntityId().equals(device.getId())).findFirst().orElse(null) - .getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue()); - } List deviceTemperatures = temperatures.stream().map(aDouble -> Double.toString(aDouble)).collect(Collectors.toList()); - Assert.assertEquals(deviceTemperatures, loadedTemperatures); + EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, null); + findByQueryAndCheckTelemetry(query, EntityKeyType.TIME_SERIES, "temperature", deviceTemperatures); pageLink = new EntityDataPageLink(10, 0, null, sortOrder); KeyFilter highTemperatureFilter = new KeyFilter(); @@ -1870,23 +1693,10 @@ public class EntityServiceTest extends AbstractServiceTest { highTemperatureFilter.setPredicate(predicate); List keyFilters = Collections.singletonList(highTemperatureFilter); - query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); - - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - - loadedEntities = new ArrayList<>(data.getData()); - while (data.hasNext()) { - query = query.next(); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); - loadedEntities.addAll(data.getData()); - } - Assert.assertEquals(highTemperatures.size(), loadedEntities.size()); - - List loadedHighTemperatures = loadedEntities.stream().map(entityData -> - entityData.getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue()).collect(Collectors.toList()); List deviceHighTemperatures = highTemperatures.stream().map(aDouble -> Double.toString(aDouble)).collect(Collectors.toList()); - Assert.assertEquals(deviceHighTemperatures, loadedHighTemperatures); + query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); + findByQueryAndCheckTelemetry(query, EntityKeyType.TIME_SERIES, "temperature", deviceHighTemperatures); deviceService.deleteDevicesByTenantId(tenantId); } @@ -1994,7 +1804,7 @@ public class EntityServiceTest extends AbstractServiceTest { EntityDataPageLink pageLink = new EntityDataPageLink(100, 0, null, sortOrder); EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFiltersEqualString); - PageData data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + PageData data = findByQueryAndCheck(query, equalStrings.size()); List loadedEntities = getLoadedEntities(data, query); Assert.assertEquals(equalStrings.size(), loadedEntities.size()); @@ -2007,7 +1817,7 @@ public class EntityServiceTest extends AbstractServiceTest { pageLink = new EntityDataPageLink(100, 0, null, sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFiltersNotEqualString); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + data = findByQueryAndCheck(query, notEqualStrings.size()); loadedEntities = getLoadedEntities(data, query); Assert.assertEquals(notEqualStrings.size(), loadedEntities.size()); @@ -2020,7 +1830,7 @@ public class EntityServiceTest extends AbstractServiceTest { pageLink = new EntityDataPageLink(100, 0, null, sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFiltersStartsWithString); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + data = findByQueryAndCheck(query, startsWithStrings.size()); loadedEntities = getLoadedEntities(data, query); Assert.assertEquals(startsWithStrings.size(), loadedEntities.size()); @@ -2033,7 +1843,7 @@ public class EntityServiceTest extends AbstractServiceTest { pageLink = new EntityDataPageLink(100, 0, null, sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFiltersEndsWithString); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + data = findByQueryAndCheck(query, endsWithStrings.size()); loadedEntities = getLoadedEntities(data, query); Assert.assertEquals(endsWithStrings.size(), loadedEntities.size()); @@ -2046,7 +1856,7 @@ public class EntityServiceTest extends AbstractServiceTest { pageLink = new EntityDataPageLink(100, 0, null, sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFiltersContainsString); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + data = findByQueryAndCheck(query, containsStrings.size()); loadedEntities = getLoadedEntities(data, query); Assert.assertEquals(containsStrings.size(), loadedEntities.size()); @@ -2059,7 +1869,7 @@ public class EntityServiceTest extends AbstractServiceTest { pageLink = new EntityDataPageLink(100, 0, null, sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFiltersNotContainsString); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + data = findByQueryAndCheck(query, notContainsStrings.size()); loadedEntities = getLoadedEntities(data, query); Assert.assertEquals(notContainsStrings.size(), loadedEntities.size()); @@ -2072,7 +1882,7 @@ public class EntityServiceTest extends AbstractServiceTest { pageLink = new EntityDataPageLink(100, 0, null, sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, deviceTypeFilters); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + data = findByQueryAndCheck(query, devices.size()); loadedEntities = getLoadedEntities(data, query); Assert.assertEquals(devices.size(), loadedEntities.size()); @@ -2117,7 +1927,7 @@ public class EntityServiceTest extends AbstractServiceTest { EntityDataPageLink pageLink = new EntityDataPageLink(100, 0, null, sortOrder); EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, null, keyFiltersEqualString); - PageData data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + PageData data = findByQueryAndCheck(query, devices.size()); List loadedEntities = getLoadedEntities(data, query); Assert.assertEquals(devices.size(), loadedEntities.size()); @@ -2132,7 +1942,7 @@ public class EntityServiceTest extends AbstractServiceTest { pageLink = new EntityDataPageLink(100, 0, null, sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, null, keyFiltersNotEqualString); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + data = findByQueryAndCheck(query, devices.size()); loadedEntities = getLoadedEntities(data, query); Assert.assertEquals(devices.size(), loadedEntities.size()); @@ -2145,7 +1955,7 @@ public class EntityServiceTest extends AbstractServiceTest { pageLink = new EntityDataPageLink(100, 0, null, sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, null, keyFiltersStartsWithString); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + data = findByQueryAndCheck(query, devices.size()); loadedEntities = getLoadedEntities(data, query); Assert.assertEquals(devices.size(), loadedEntities.size()); @@ -2158,7 +1968,7 @@ public class EntityServiceTest extends AbstractServiceTest { pageLink = new EntityDataPageLink(100, 0, null, sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, null, keyFiltersEndsWithString); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + data = findByQueryAndCheck(query, devices.size()); loadedEntities = getLoadedEntities(data, query); Assert.assertEquals(devices.size(), loadedEntities.size()); @@ -2171,7 +1981,7 @@ public class EntityServiceTest extends AbstractServiceTest { pageLink = new EntityDataPageLink(100, 0, null, sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, null, keyFiltersContainsString); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + data = findByQueryAndCheck(query, devices.size()); loadedEntities = getLoadedEntities(data, query); Assert.assertEquals(devices.size(), loadedEntities.size()); @@ -2184,7 +1994,7 @@ public class EntityServiceTest extends AbstractServiceTest { pageLink = new EntityDataPageLink(100, 0, null, sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, null, keyFiltersNotContainsString); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + data = findByQueryAndCheck(query, devices.size()); loadedEntities = getLoadedEntities(data, query); Assert.assertEquals(devices.size(), loadedEntities.size()); @@ -2232,7 +2042,7 @@ public class EntityServiceTest extends AbstractServiceTest { EntityDataPageLink pageLink = new EntityDataPageLink(100, 0, null, sortOrder); EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, null, deviceTypeFilters); - PageData data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + PageData data = findByQueryAndCheck(query, devices.size()); List loadedEntities = getLoadedEntities(data, query); Assert.assertEquals(devices.size(), loadedEntities.size()); @@ -2240,7 +2050,7 @@ public class EntityServiceTest extends AbstractServiceTest { pageLink = new EntityDataPageLink(100, 0, null, sortOrder); query = new EntityDataQuery(filter, pageLink, entityFields, null, createdTimeFilters); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + data = findByQueryAndCheck(query, devices.size()); loadedEntities = getLoadedEntities(data, query); Assert.assertEquals(devices.size(), loadedEntities.size()); @@ -2248,7 +2058,7 @@ public class EntityServiceTest extends AbstractServiceTest { pageLink = new EntityDataPageLink(100, 0, null, null); query = new EntityDataQuery(filter, pageLink, entityFields, null, nameFilters); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + data = findByQueryAndCheck(query, devices.size()); loadedEntities = getLoadedEntities(data, query); Assert.assertEquals(devices.size(), loadedEntities.size()); @@ -2296,12 +2106,12 @@ public class EntityServiceTest extends AbstractServiceTest { // query with textSearch - optimization is not performing EntityDataPageLink originalPageLink = new EntityDataPageLink(pageSize, 0, "Device", sortOrder); EntityDataQuery originalQuery = new EntityDataQuery(filter, originalPageLink, entityFields, null, deviceTypeFilters); - PageData originalData = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), originalQuery); + PageData originalData = findByQueryAndCheck(originalQuery, expectedDevicesSize); // query without textSearch - optimization is performing EntityDataPageLink optimizedPageLink = new EntityDataPageLink(pageSize, 0, null, sortOrder); EntityDataQuery optimizedQuery = new EntityDataQuery(filter, optimizedPageLink, entityFields, null, deviceTypeFilters); - PageData optimizedData = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), optimizedQuery); + PageData optimizedData = findByQueryAndCheck(optimizedQuery, expectedDevicesSize); List loadedEntities = getLoadedEntities(optimizedData, optimizedQuery); Assert.assertEquals(expectedDevicesSize, loadedEntities.size()); loadedEntities = getLoadedEntities(originalData, originalQuery); @@ -2325,12 +2135,12 @@ public class EntityServiceTest extends AbstractServiceTest { // query with textSearch - optimization is not performing originalPageLink = new EntityDataPageLink(pageSize, 0, "Device", sortOrder); originalQuery = new EntityDataQuery(filter, originalPageLink, entityFields, null, attributeFilters); - originalData = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), originalQuery); + originalData = findByQuery(originalQuery); // query without textSearch - optimization is performing optimizedPageLink = new EntityDataPageLink(pageSize, 0, null, sortOrder); optimizedQuery = new EntityDataQuery(filter, optimizedPageLink, entityFields, null, attributeFilters); - optimizedData = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), optimizedQuery); + optimizedData = findByQuery(optimizedQuery); loadedEntities = getLoadedEntities(optimizedData, optimizedQuery); Assert.assertEquals(expectedDevicesSize, loadedEntities.size()); loadedEntities = getLoadedEntities(originalData, originalQuery); @@ -2354,12 +2164,12 @@ public class EntityServiceTest extends AbstractServiceTest { // query with textSearch - optimization is not performing originalPageLink = new EntityDataPageLink(pageSize, 0, "Device", sortOrder); originalQuery = new EntityDataQuery(filter, originalPageLink, entityFields, null, nameFilters); - originalData = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), originalQuery); + originalData = findByQuery(originalQuery); // query without textSearch - optimization is performing optimizedPageLink = new EntityDataPageLink(pageSize, 0, null, sortOrder); optimizedQuery = new EntityDataQuery(filter, optimizedPageLink, entityFields, null, nameFilters); - optimizedData = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), optimizedQuery); + optimizedData = findByQuery(optimizedQuery); loadedEntities = getLoadedEntities(optimizedData, optimizedQuery); Assert.assertEquals(expectedDevicesSize, loadedEntities.size()); loadedEntities = getLoadedEntities(originalData, originalQuery); @@ -2387,10 +2197,9 @@ public class EntityServiceTest extends AbstractServiceTest { private List getLoadedEntities(PageData data, EntityDataQuery query) { List loadedEntities = new ArrayList<>(data.getData()); - while (data.hasNext()) { query = query.next(); - data = entityService.findEntityDataByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), query); + data = findByQuery(query); loadedEntities.addAll(data.getData()); } return loadedEntities; @@ -2421,25 +2230,25 @@ public class EntityServiceTest extends AbstractServiceTest { private ListenableFuture> saveLongAttribute(EntityId entityId, String key, long value, AttributeScope scope) { KvEntry attrValue = new LongDataEntry(key, value); AttributeKvEntry attr = new BaseAttributeKvEntry(attrValue, 42L); - return attributesService.save(SYSTEM_TENANT_ID, entityId, scope, Collections.singletonList(attr)); + return attributesService.save(tenantId, entityId, scope, Collections.singletonList(attr)); } private ListenableFuture> saveStringAttribute(EntityId entityId, String key, String value, AttributeScope scope) { KvEntry attrValue = new StringDataEntry(key, value); AttributeKvEntry attr = new BaseAttributeKvEntry(attrValue, 42L); - return attributesService.save(SYSTEM_TENANT_ID, entityId, scope, Collections.singletonList(attr)); + return attributesService.save(tenantId, entityId, scope, Collections.singletonList(attr)); } - private ListenableFuture saveLongTimeseries(EntityId entityId, String key, Double value) { + private ListenableFuture saveLongTimeseries(EntityId entityId, String key, Double value) { TsKvEntity tsKv = new TsKvEntity(); tsKv.setStrKey(key); tsKv.setDoubleValue(value); KvEntry telemetryValue = new DoubleDataEntry(key, value); BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, telemetryValue); - return timeseriesService.save(SYSTEM_TENANT_ID, entityId, timeseries); + return timeseriesService.save(tenantId, entityId, timeseries); } - private void createMultiRootHierarchy(List buildings, List apartments, + protected void createMultiRootHierarchy(List buildings, List apartments, Map> entityNameByTypeMap, Map childParentRelationMap) throws InterruptedException { for (int k = 0; k < 3; k++) { @@ -2510,4 +2319,93 @@ public class EntityServiceTest extends AbstractServiceTest { } } } + + @Test + public void testFindEntitiesWithEntityViewFilter() { + EntityView entityView = new EntityView(); + entityView.setTenantId(tenantId); + entityView.setCustomerId(customerId); + entityView.setName("test"); + entityView.setType("default"); + entityView.setEntityId(new DeviceId(UUID.randomUUID())); + entityView.setKeys(new TelemetryEntityView(List.of("test"), null)); + entityView.setStartTimeMs(124); + entityView.setEndTimeMs(256); + entityView.setExternalId(new EntityViewId(UUID.randomUUID())); + entityView.setAdditionalInfo(JacksonUtil.newObjectNode().put("test", "test")); + entityView = entityViewService.saveEntityView(entityView); + + EntityViewTypeFilter entityViewTypeFilter = new EntityViewTypeFilter(); + entityViewTypeFilter.setEntityViewNameFilter("test"); + entityViewTypeFilter.setEntityViewTypes(List.of("non-existing", "default")); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, null); + List entityFields = List.of( + new EntityKey(EntityKeyType.ENTITY_FIELD, "name") + ); + EntityDataQuery query = new EntityDataQuery(entityViewTypeFilter, pageLink, entityFields, Collections.emptyList(), null); + + PageData relationsResult = findByQueryAndCheck(new CustomerId(EntityId.NULL_UUID), query, 1); + assertThat(relationsResult.getData().get(0).getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()).isEqualTo(entityView.getName()); + + // find with non existing name + entityViewTypeFilter.setEntityViewNameFilter("non-existing"); + findByQueryAndCheck(new CustomerId(EntityId.NULL_UUID), query, 0); + + // find with non existing type + entityViewTypeFilter.setEntityViewNameFilter(null); + entityViewTypeFilter.setEntityViewTypes(Collections.singletonList("non-existing")); + + findByQueryAndCheck(new CustomerId(EntityId.NULL_UUID), query, 0); + } + + protected PageData findByQuery(EntityDataQuery query) { + return findByQuery(new CustomerId(CustomerId.NULL_UUID), query); + } + + protected PageData findByQuery(CustomerId customerId, EntityDataQuery query) { + return entityService.findEntityDataByQuery(tenantId, customerId, query); + } + + protected PageData findByQueryAndCheck(EntityDataQuery query, long expectedResultSize) { + return findByQueryAndCheck(new CustomerId(CustomerId.NULL_UUID), query, expectedResultSize); + } + + protected PageData findByQueryAndCheck(CustomerId customerId, EntityDataQuery query, long expectedResultSize) { + PageData result = entityService.findEntityDataByQuery(tenantId, customerId, query); + assertThat(result.getTotalElements()).isEqualTo(expectedResultSize); + return result; + } + + protected List findByQueryAndCheckTelemetry(EntityDataQuery query, EntityKeyType entityKeyType, String key, List expectedTelemetry) { + List loadedEntities = findEntitiesTelemetry(query, entityKeyType, key, expectedTelemetry); + List entitiesTelemetry = loadedEntities.stream().map(entityData -> entityData.getLatest().get(entityKeyType).get(key).getValue()).toList(); + assertThat(entitiesTelemetry).containsExactlyInAnyOrderElementsOf(expectedTelemetry); + return loadedEntities; + } + + protected List findEntitiesTelemetry(EntityDataQuery query, EntityKeyType entityKeyType, String key, List expectedTelemetries) { + PageData data = findByQueryAndCheck(query, expectedTelemetries.size()); + List loadedEntities = new ArrayList<>(data.getData()); + while (data.hasNext()) { + query = query.next(); + data = findByQuery(query); + loadedEntities.addAll(data.getData()); + } + return loadedEntities; + } + + protected long countByQuery(CustomerId customerId, EntityCountQuery query) { + return entityService.countEntitiesByQuery(tenantId, customerId, query); + } + + protected long countByQueryAndCheck(EntityCountQuery countQuery, int expectedResult) { + return countByQueryAndCheck(new CustomerId(CustomerId.NULL_UUID), countQuery, expectedResult); + } + + protected long countByQueryAndCheck(CustomerId customerId, EntityCountQuery query, int expectedResult) { + long result = countByQuery(customerId, query); + assertThat(result).isEqualTo(expectedResult); + return result; + } + } diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmServiceTest.java index b7f1f62582..ac85b48bc1 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmServiceTest.java @@ -38,10 +38,17 @@ import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.dao.alarm.AlarmService; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.edge.EdgeService; +import org.thingsboard.server.dao.entity.EntityService; +import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.service.entitiy.TbLogEntityActionService; import org.thingsboard.server.service.executors.DbCallbackExecutorService; +import org.thingsboard.server.service.security.permission.AccessControlService; import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; @@ -81,6 +88,20 @@ public class DefaultTbAlarmServiceTest { protected TbClusterService tbClusterService; @MockBean private EntitiesVersionControlService vcService; + @MockBean + private AccessControlService accessControlService; + @MockBean + private TenantService tenantService; + @MockBean + private AssetService assetService; + @MockBean + private DeviceService deviceService; + @MockBean + private AssetProfileService assetProfileService; + @MockBean + private DeviceProfileService deviceProfileService; + @MockBean + private EntityService entityService; @SpyBean DefaultTbAlarmService service; diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/alarmComment/DefaultTbAlarmCommentServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/alarmComment/DefaultTbAlarmCommentServiceTest.java index 0196857b1e..3c00c2957a 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/alarmComment/DefaultTbAlarmCommentServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/alarmComment/DefaultTbAlarmCommentServiceTest.java @@ -35,10 +35,17 @@ import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.alarm.AlarmService; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entity.EntityService; +import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.service.entitiy.TbLogEntityActionService; import org.thingsboard.server.service.entitiy.alarm.DefaultTbAlarmCommentService; import org.thingsboard.server.service.executors.DbCallbackExecutorService; +import org.thingsboard.server.service.security.permission.AccessControlService; import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; import java.util.UUID; @@ -72,6 +79,20 @@ public class DefaultTbAlarmCommentServiceTest { protected CustomerService customerService; @MockBean protected TbClusterService tbClusterService; + @MockBean + private AccessControlService accessControlService; + @MockBean + private TenantService tenantService; + @MockBean + private AssetService assetService; + @MockBean + private DeviceService deviceService; + @MockBean + private AssetProfileService assetProfileService; + @MockBean + private DeviceProfileService deviceProfileService; + @MockBean + private EntityService entityService; @SpyBean DefaultTbAlarmCommentService service; diff --git a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbClusterServiceTest.java b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbClusterServiceTest.java index 9b6c5e6779..40369e231a 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbClusterServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbClusterServiceTest.java @@ -44,6 +44,7 @@ import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.queue.TbQueueCallback; @@ -102,6 +103,8 @@ public class DefaultTbClusterServiceTest { protected TbRuleEngineProducerService ruleEngineProducerService; @MockBean protected TbTransactionalCache edgeCache; + @MockBean + protected CalculatedFieldService calculatedFieldService; @SpyBean protected TopicService topicService; diff --git a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java index fe222237c6..26832f6176 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java @@ -532,6 +532,98 @@ public class DefaultTbCoreConsumerServiceTest { then(statsMock).should(never()).log(inactivityMsg); } + @Test + public void givenProcessingSuccess_whenForwardingInactivityTimeoutUpdateMsgToStateService_thenOnSuccessCallbackIsCalled() { + // GIVEN + var inactivityTimeoutUpdateMsg = TransportProtos.DeviceInactivityTimeoutUpdateProto.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) + .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) + .setInactivityTimeout(time) + .build(); + + doCallRealMethod().when(defaultTbCoreConsumerServiceMock).forwardToStateService(inactivityTimeoutUpdateMsg, tbCallbackMock); + + // WHEN + defaultTbCoreConsumerServiceMock.forwardToStateService(inactivityTimeoutUpdateMsg, tbCallbackMock); + + // THEN + then(stateServiceMock).should().onDeviceInactivityTimeoutUpdate(tenantId, deviceId, time); + then(tbCallbackMock).should().onSuccess(); + then(tbCallbackMock).should(never()).onFailure(any()); + } + + @Test + public void givenProcessingFailure_whenForwardingInactivityTimeoutUpdateMsgToStateService_thenOnFailureCallbackIsCalled() { + // GIVEN + var inactivityTimeoutUpdateMsg = TransportProtos.DeviceInactivityTimeoutUpdateProto.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) + .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) + .setInactivityTimeout(time) + .build(); + + doCallRealMethod().when(defaultTbCoreConsumerServiceMock).forwardToStateService(inactivityTimeoutUpdateMsg, tbCallbackMock); + + var runtimeException = new RuntimeException("Something bad happened!"); + doThrow(runtimeException).when(stateServiceMock).onDeviceInactivityTimeoutUpdate(tenantId, deviceId, time); + + // WHEN + defaultTbCoreConsumerServiceMock.forwardToStateService(inactivityTimeoutUpdateMsg, tbCallbackMock); + + // THEN + then(tbCallbackMock).should(never()).onSuccess(); + then(tbCallbackMock).should().onFailure(runtimeException); + } + + @Test + public void givenStatsEnabled_whenForwardingInactivityTimeoutUpdateMsgToStateService_thenStatsAreRecorded() { + // GIVEN + ReflectionTestUtils.setField(defaultTbCoreConsumerServiceMock, "stats", statsMock); + ReflectionTestUtils.setField(defaultTbCoreConsumerServiceMock, "statsEnabled", true); + + var inactivityTimeoutUpdateMsg = TransportProtos.DeviceInactivityTimeoutUpdateProto.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) + .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) + .setInactivityTimeout(time) + .build(); + + doCallRealMethod().when(defaultTbCoreConsumerServiceMock).forwardToStateService(inactivityTimeoutUpdateMsg, tbCallbackMock); + + // WHEN + defaultTbCoreConsumerServiceMock.forwardToStateService(inactivityTimeoutUpdateMsg, tbCallbackMock); + + // THEN + then(statsMock).should().log(inactivityTimeoutUpdateMsg); + } + + @Test + public void givenStatsDisabled_whenForwardingInactivityTimeoutUpdateMsgToStateService_thenStatsAreNotRecorded() { + // GIVEN + ReflectionTestUtils.setField(defaultTbCoreConsumerServiceMock, "stats", statsMock); + ReflectionTestUtils.setField(defaultTbCoreConsumerServiceMock, "statsEnabled", false); + + var inactivityTimeoutUpdateMsg = TransportProtos.DeviceInactivityTimeoutUpdateProto.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) + .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) + .setInactivityTimeout(time) + .build(); + + doCallRealMethod().when(defaultTbCoreConsumerServiceMock).forwardToStateService(inactivityTimeoutUpdateMsg, tbCallbackMock); + + // WHEN + defaultTbCoreConsumerServiceMock.forwardToStateService(inactivityTimeoutUpdateMsg, tbCallbackMock); + + // THEN + then(statsMock).should(never()).log(inactivityTimeoutUpdateMsg); + } + @Test public void givenRestApiCallResponseMsgProto_whenForwardToRuleEngineCallService_thenCallOnQueueMsg() { // GIVEN @@ -545,4 +637,5 @@ public class DefaultTbCoreConsumerServiceTest { // THEN then(ruleEngineCallServiceMock).should().onQueueMsg(restApiCallResponseMsgProto, tbCallbackMock); } + } diff --git a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java index 8141b924f6..314c76b058 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java @@ -626,8 +626,7 @@ public class TbRuleEngineQueueConsumerManagerTest { .until(() -> consumer.subscribed && consumer.getPartitions().equals(expectedPartitions) && consumer.pollingStarted); verify(consumer, times(1)).subscribe(any()); verify(consumer).subscribe(eq(expectedPartitions)); - verify(consumer).doSubscribe(argThat(topics -> topics.containsAll(expectedPartitions.stream() - .map(TopicPartitionInfo::getFullTopicName).collect(Collectors.toList())))); + verify(consumer).doSubscribe(argThat(topics -> topics.containsAll(expectedPartitions))); verify(consumer, atLeastOnce()).poll(eq((long) queue.getPollInterval())); verify(consumer, atLeastOnce()).doPoll(eq((long) queue.getPollInterval())); verify(consumer, never()).unsubscribe(); @@ -743,9 +742,11 @@ public class TbRuleEngineQueueConsumerManagerTest { } @Override - protected void doSubscribe(List topicNames) { - log.debug("doSubscribe({})", topicNames); - this.topics = topicNames; + protected void doSubscribe(Set partitions) { + this.topics = partitions.stream() + .map(TopicPartitionInfo::getFullTopicName) + .collect(Collectors.toList()); + log.debug("doSubscribe({})", topics); subscribed = true; } diff --git a/application/src/test/java/org/thingsboard/server/service/script/AbstractTbelInvokeTest.java b/application/src/test/java/org/thingsboard/server/service/script/AbstractTbelInvokeTest.java index f8563e5e2a..e3383e32a8 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/AbstractTbelInvokeTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/AbstractTbelInvokeTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.script; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.thingsboard.common.util.JacksonUtil; @@ -22,7 +23,7 @@ import org.thingsboard.script.api.ScriptType; import org.thingsboard.script.api.tbel.DefaultTbelInvokeService; import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.common.stats.DefaultStatsFactory; import java.util.Map; import java.util.UUID; @@ -30,7 +31,7 @@ import java.util.concurrent.ExecutionException; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_TELEMETRY_REQUEST; -@SpringBootTest(classes = DefaultTbelInvokeService.class) +@SpringBootTest(classes = {SimpleMeterRegistry.class, DefaultStatsFactory.class, DefaultTbelInvokeService.class }) public abstract class AbstractTbelInvokeTest { @Autowired diff --git a/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java b/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java index 7404427f92..363f21fa10 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java @@ -21,9 +21,13 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.script.api.ScriptType; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.stats.DefaultStatsFactory; +import org.thingsboard.server.common.stats.StatsCounter; +import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.common.stats.TbApiUsageStateClient; import org.thingsboard.server.gen.js.JsInvokeProtos; @@ -68,6 +72,10 @@ class RemoteJsInvokeServiceTest { remoteJsInvokeService = new RemoteJsInvokeService(Optional.of(apiUsageStateClient), Optional.of(apiUsageReportClient)); jsRequestTemplate = mock(TbQueueRequestTemplate.class); remoteJsInvokeService.requestTemplate = jsRequestTemplate; + StatsFactory statsFactory = mock(StatsFactory.class); + when(statsFactory.createStatsCounter(any(), any())).thenReturn(mock(StatsCounter.class)); + ReflectionTestUtils.setField(remoteJsInvokeService, "statsFactory",statsFactory); + remoteJsInvokeService.init(); } @AfterEach diff --git a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java index 8c8b82928a..bf86f98e6e 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java @@ -21,6 +21,7 @@ import org.thingsboard.script.api.tbel.TbDate; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.Base64; import java.util.Collections; import java.util.Comparator; @@ -146,6 +147,31 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { assertEquals(expected, actual); } + @Test + public void mapsImplicitIterationWithoutEntrySet() throws ExecutionException, InterruptedException { + msgStr = msgMapStr; + decoderStr = """ + foreach(element : msg){ + if(element.getKey() == null){ + return raiseError("Bad getKey"); + } + if(element.key == null){ + return raiseError("Bad key"); + } + if(element.getValue() == null){ + return raiseError("Bad getValue"); + } + if(element.value == null){ + return raiseError("Bad value"); + } + } + return msg; + """; + LinkedHashMap expected = expectedMap; + Object actual = invokeScript(evalScript(decoderStr), msgStr); + assertEquals(expected, actual); + } + @Test public void mapsGetInfoSize_Test() throws ExecutionException, InterruptedException { msgStr = msgMapStr; @@ -660,14 +686,13 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { """; decoderStr = """ var list = msg.list; - var listAdd = ["thigsboard", 4, 67]; return { list: list.clone(), length: list.length(), memorySize: list.memorySize(), indOf1: list.indexOf("B", 1), indOf2: list.indexOf(2, 2), - sStr: list.validateClazzInArrayIsOnlyString() + sStr: list.validateClazzInArrayIsOnlyNumber() } """; ArrayList list = new ArrayList<>(List.of(67, 2, 2, 2)); @@ -677,7 +702,7 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { expected.put("memorySize", 32L); expected.put("indOf1", -1); expected.put("indOf2", 2); - expected.put("sStr", false); + expected.put("sStr", true); Object actual = invokeScript(evalScript(decoderStr), msgStr); assertEquals(expected, actual); } @@ -2148,6 +2173,177 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { assertEquals(expected, actual); } + @Test + public void toUnmodifiableExecutionArrayList_Test() throws ExecutionException, InterruptedException { + msgStr = "{}"; + decoderStr = String.format(""" + var original = []; + original.add(0x35); + var unmodifiable = original.toUnmodifiable(); + msg.result = unmodifiable; + return {msg: msg}; + """); + LinkedHashMap expected = new LinkedHashMap<>(); + List expectedList = Arrays.asList(0x35); + LinkedHashMap expectedResult = new LinkedHashMap<>(); + expectedResult.put("result", expectedList); + expected.put("msg", expectedResult); + Object actual = invokeScript(evalScript(decoderStr), msgStr); + assertEquals(expected, actual); + + decoderStr = String.format(""" + var original = []; + original.add(0x67); + var unmodifiable = original.toUnmodifiable(); + unmodifiable.add(0x35); + msg.result = unmodifiable; + return {msg: msg}; + """); + assertThatThrownBy(() -> { + invokeScript(evalScript(decoderStr), msgStr); + }).hasMessageContaining("Error: unmodifiable.add(0x35): List is unmodifiable"); + } + + + @Test + public void toUnmodifiableExecutionHashMap_Test() throws ExecutionException, InterruptedException { + msgStr = "{}"; + decoderStr = String.format(""" + var original = {}; + original.putIfAbsent("entry1", 73); + var unmodifiable = original.toUnmodifiable(); + msg.result = unmodifiable; + return {msg: msg}; + """); + LinkedHashMap expected = new LinkedHashMap<>(); + LinkedHashMap expectedMap = new LinkedHashMap<>(Map.of("entry1", 73)); + LinkedHashMap expectedResult = new LinkedHashMap<>(); + expectedResult.put("result", expectedMap); + expected.put("msg", expectedResult); + Object actual = invokeScript(evalScript(decoderStr), msgStr); + assertEquals(expected, actual); + + decoderStr = String.format(""" + var original = {}; + original.humidity = 73; + var unmodifiable = original.toUnmodifiable(); + unmodifiable.put("temperature1", 96); + msg.result = unmodifiable; + return {msg: msg}; + """); + assertThatThrownBy(() -> { + invokeScript(evalScript(decoderStr), msgStr); + }).hasMessageContaining("Error: unmodifiable.put(\"temperature1\", 96): Map is unmodifiable"); + } + + @Test + public void tbDateFunction_Test() throws ExecutionException, InterruptedException { + String stringDateUTC = "2024-01-01T10:00:00.00Z"; + TbDate d = new TbDate(stringDateUTC); + + msgStr = "{}"; + decoderStr = String.format(""" + var d = new Date("%s"); // TZ => "UTC" + var dIsoY1 = d.toISOString(); // return 2024-01-01T10:00:00Z + d.addYears(1); + var dIsoY2 = d.toISOString(); // return 2025-01-01T10:00:00Z + d.addYears(-2); + var dIsoY3 = d.toISOString(); // return 2023-01-01T10:00:00Z + d.addMonths(2); + var dIsoM1 = d.toISOString(); // return 2023-03-01T10:00:00Z + d.addMonths(10); + var dIsoM2 = d.toISOString(); // return 2024-01-01T10:00:00Z + d.addMonths(-13); + var dIsoM3 = d.toISOString(); // return 2022-12-01T10:00:00Z + d.addWeeks(4); + var dIsoW1 = d.toISOString(); // return 2022-12-29T10:00:00Z + d.addWeeks(-5); + var dIsoW2 = d.toISOString(); // return 2022-11-24T10:00:00Z + d.addDays(6); + var dIsoD1 = d.toISOString(); // return 2022-11-30T10:00:00Z + d.addDays(45); + var dIsoD2 = d.toISOString(); // return 2023-01-14T10:00:00Z + d.addDays(-50); + var dIsoD3 = d.toISOString(); // return 2022-11-25T10:00:00Z + d.addHours(23); + var dIsoH1 = d.toISOString(); // return 2022-11-26T09:00:00Z + d.addHours(-47); + var dIsoH2 = d.toISOString(); // return 2022-11-24T10:00:00Z + d.addMinutes(59); + var dIsoMin1 = d.toISOString(); // return 2022-11-24T10:59:00Z + d.addMinutes(-60); + var dIsoMin2 = d.toISOString(); // return 2022-11-24T09:59:00Z + d.addSeconds(59); + var dIsoS1 = d.toISOString(); // return 2022-11-24T09:59:59Z + d.addSeconds(-60); + var dIsoS2 = d.toISOString(); // return 2022-11-24T09:58:59Z + d.addNanos(999999); + var dIsoN1 = d.toISOString(); // return 2022-11-24T09:58:59.000999999Z + d.addNanos(-1000000); + var dIsoN2 = d.toISOString(); // return 2022-11-24T09:58:58.999999999Z + return { + "dIsoY1": dIsoY1, + "dIsoY2": dIsoY2, + "dIsoY3": dIsoY3, + "dIsoM1": dIsoM1, + "dIsoM2": dIsoM2, + "dIsoM3": dIsoM3, + "dIsoW1": dIsoW1, + "dIsoW2": dIsoW2, + "dIsoD1": dIsoD1, + "dIsoD2": dIsoD2, + "dIsoD3": dIsoD3, + "dIsoH1": dIsoH1, + "dIsoH2": dIsoH2, + "dIsoMin1": dIsoMin1, + "dIsoMin2": dIsoMin2, + "dIsoS1": dIsoS1, + "dIsoS2": dIsoS2, + "dIsoN1": dIsoN1, + "dIsoN2": dIsoN2 + } + """, stringDateUTC); + LinkedHashMap expected = new LinkedHashMap<>(); + expected.put("dIsoY1", d.toISOString()); + d.addYears(1); + expected.put("dIsoY2", d.toISOString()); + d.addYears(-2); + expected.put("dIsoY3", d.toISOString()); + d.addMonths(2); + expected.put("dIsoM1", d.toISOString()); + d.addMonths(10); + expected.put("dIsoM2", d.toISOString()); + d.addMonths(-13); + expected.put("dIsoM3", d.toISOString()); + d.addWeeks(4); + expected.put("dIsoW1", d.toISOString()); + d.addWeeks(-5); + expected.put("dIsoW2", d.toISOString()); + d.addDays(6); + expected.put("dIsoD1", d.toISOString()); + d.addDays(45); + expected.put("dIsoD2", d.toISOString()); + d.addDays(-50); + expected.put("dIsoD3", d.toISOString()); + d.addHours(23); + expected.put("dIsoH1", d.toISOString()); + d.addHours(-47); + expected.put("dIsoH2", d.toISOString()); + d.addMinutes(59); + expected.put("dIsoMin1", d.toISOString()); + d.addMinutes(-60); + expected.put("dIsoMin2", d.toISOString()); + d.addSeconds(59); + expected.put("dIsoS1", d.toISOString()); + d.addSeconds(-60); + expected.put("dIsoS2", d.toISOString()); + d.addNanos(999999); + expected.put("dIsoN1", d.toISOString()); + d.addNanos(-1000000); + expected.put("dIsoN2", d.toISOString()); + Object actual = invokeScript(evalScript(decoderStr), msgStr); + assertEquals(expected, actual); + } private List splice(List oldList, int start, int deleteCount, Object... values) { start = initStartIndex(oldList, start); diff --git a/application/src/test/java/org/thingsboard/server/service/state/DefaultRuleEngineDeviceStateManagerTest.java b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateManagerTest.java similarity index 71% rename from application/src/test/java/org/thingsboard/server/service/state/DefaultRuleEngineDeviceStateManagerTest.java rename to application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateManagerTest.java index 28ea2d270a..6cf0ab9053 100644 --- a/application/src/test/java/org/thingsboard/server/service/state/DefaultRuleEngineDeviceStateManagerTest.java +++ b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateManagerTest.java @@ -52,7 +52,7 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) -public class DefaultRuleEngineDeviceStateManagerTest { +public class DefaultDeviceStateManagerTest { @Mock private DeviceStateService deviceStateServiceMock; @@ -71,7 +71,7 @@ public class DefaultRuleEngineDeviceStateManagerTest { @Captor private ArgumentCaptor queueCallbackCaptor; - private DefaultRuleEngineDeviceStateManager deviceStateManager; + private DefaultDeviceStateManager deviceStateManager; private static final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("57ab2e6c-bc4c-11ee-a506-0242ac120002")); private static final DeviceId DEVICE_ID = DeviceId.fromString("74a9053e-bc4c-11ee-a506-0242ac120002"); @@ -82,7 +82,7 @@ public class DefaultRuleEngineDeviceStateManagerTest { @BeforeEach public void setup() { - deviceStateManager = new DefaultRuleEngineDeviceStateManager(serviceInfoProviderMock, partitionServiceMock, Optional.of(deviceStateServiceMock), clusterServiceMock); + deviceStateManager = new DefaultDeviceStateManager(serviceInfoProviderMock, partitionServiceMock, Optional.of(deviceStateServiceMock), clusterServiceMock); } @ParameterizedTest @@ -90,7 +90,7 @@ public class DefaultRuleEngineDeviceStateManagerTest { "when onDeviceX() is called, then should route event to local service and call onSuccess() callback.") @MethodSource public void givenRoutedToLocalAndProcessingSuccess_whenOnDeviceAction_thenShouldCallLocalServiceAndSuccessCallback( - BiConsumer onDeviceAction, Consumer actionVerification + BiConsumer onDeviceAction, Consumer actionVerification ) { // GIVEN given(serviceInfoProviderMock.isService(ServiceType.TB_CORE)).willReturn(true); @@ -109,20 +109,24 @@ public class DefaultRuleEngineDeviceStateManagerTest { private static Stream givenRoutedToLocalAndProcessingSuccess_whenOnDeviceAction_thenShouldCallLocalServiceAndSuccessCallback() { return Stream.of( Arguments.of( - (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceConnect(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), + (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceConnect(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), (Consumer) deviceStateServiceMock -> then(deviceStateServiceMock).should().onDeviceConnect(TENANT_ID, DEVICE_ID, EVENT_TS) ), Arguments.of( - (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceActivity(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), + (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceActivity(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), (Consumer) deviceStateServiceMock -> then(deviceStateServiceMock).should().onDeviceActivity(TENANT_ID, DEVICE_ID, EVENT_TS) ), Arguments.of( - (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceDisconnect(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), + (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceDisconnect(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), (Consumer) deviceStateServiceMock -> then(deviceStateServiceMock).should().onDeviceDisconnect(TENANT_ID, DEVICE_ID, EVENT_TS) ), Arguments.of( - (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceInactivity(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), + (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceInactivity(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), (Consumer) deviceStateServiceMock -> then(deviceStateServiceMock).should().onDeviceInactivity(TENANT_ID, DEVICE_ID, EVENT_TS) + ), + Arguments.of( + (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceInactivityTimeoutUpdate(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), + (Consumer) deviceStateServiceMock -> then(deviceStateServiceMock).should().onDeviceInactivityTimeoutUpdate(TENANT_ID, DEVICE_ID, EVENT_TS) ) ); } @@ -132,7 +136,7 @@ public class DefaultRuleEngineDeviceStateManagerTest { "when onDeviceX() is called, then should route event to local service and call onFailure() callback.") @MethodSource public void givenRoutedToLocalAndProcessingFailure_whenOnDeviceAction_thenShouldCallLocalServiceAndFailureCallback( - Consumer exceptionThrowSetup, BiConsumer onDeviceAction, Consumer actionVerification + Consumer exceptionThrowSetup, BiConsumer onDeviceAction, Consumer actionVerification ) { // GIVEN given(serviceInfoProviderMock.isService(ServiceType.TB_CORE)).willReturn(true); @@ -155,23 +159,28 @@ public class DefaultRuleEngineDeviceStateManagerTest { return Stream.of( Arguments.of( (Consumer) deviceStateServiceMock -> doThrow(RUNTIME_EXCEPTION).when(deviceStateServiceMock).onDeviceConnect(TENANT_ID, DEVICE_ID, EVENT_TS), - (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceConnect(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), + (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceConnect(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), (Consumer) deviceStateServiceMock -> then(deviceStateServiceMock).should().onDeviceConnect(TENANT_ID, DEVICE_ID, EVENT_TS) ), Arguments.of( (Consumer) deviceStateServiceMock -> doThrow(RUNTIME_EXCEPTION).when(deviceStateServiceMock).onDeviceActivity(TENANT_ID, DEVICE_ID, EVENT_TS), - (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceActivity(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), + (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceActivity(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), (Consumer) deviceStateServiceMock -> then(deviceStateServiceMock).should().onDeviceActivity(TENANT_ID, DEVICE_ID, EVENT_TS) ), Arguments.of( (Consumer) deviceStateServiceMock -> doThrow(RUNTIME_EXCEPTION).when(deviceStateServiceMock).onDeviceDisconnect(TENANT_ID, DEVICE_ID, EVENT_TS), - (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceDisconnect(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), + (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceDisconnect(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), (Consumer) deviceStateServiceMock -> then(deviceStateServiceMock).should().onDeviceDisconnect(TENANT_ID, DEVICE_ID, EVENT_TS) ), Arguments.of( (Consumer) deviceStateServiceMock -> doThrow(RUNTIME_EXCEPTION).when(deviceStateServiceMock).onDeviceInactivity(TENANT_ID, DEVICE_ID, EVENT_TS), - (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceInactivity(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), + (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceInactivity(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), (Consumer) deviceStateServiceMock -> then(deviceStateServiceMock).should().onDeviceInactivity(TENANT_ID, DEVICE_ID, EVENT_TS) + ), + Arguments.of( + (Consumer) deviceStateServiceMock -> doThrow(RUNTIME_EXCEPTION).when(deviceStateServiceMock).onDeviceInactivityTimeoutUpdate(TENANT_ID, DEVICE_ID, EVENT_TS), + (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceInactivityTimeoutUpdate(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), + (Consumer) deviceStateServiceMock -> then(deviceStateServiceMock).should().onDeviceInactivityTimeoutUpdate(TENANT_ID, DEVICE_ID, EVENT_TS) ) ); } @@ -181,7 +190,7 @@ public class DefaultRuleEngineDeviceStateManagerTest { "when onDeviceX() is called, then should send correct queue message to external service with correct callback object.") @MethodSource public void givenRoutedToExternal_whenOnDeviceAction_thenShouldSendQueueMsgToExternalServiceWithCorrectCallback( - BiConsumer onDeviceAction, BiConsumer> actionVerification + BiConsumer onDeviceAction, BiConsumer> actionVerification ) { // WHEN ReflectionTestUtils.setField(deviceStateManager, "deviceStateService", Optional.empty()); @@ -203,7 +212,7 @@ public class DefaultRuleEngineDeviceStateManagerTest { private static Stream givenRoutedToExternal_whenOnDeviceAction_thenShouldSendQueueMsgToExternalServiceWithCorrectCallback() { return Stream.of( Arguments.of( - (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceConnect(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), + (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceConnect(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), (BiConsumer>) (clusterServiceMock, queueCallbackCaptor) -> { var deviceConnectMsg = TransportProtos.DeviceConnectProto.newBuilder() .setTenantIdMSB(TENANT_ID.getId().getMostSignificantBits()) @@ -219,7 +228,7 @@ public class DefaultRuleEngineDeviceStateManagerTest { } ), Arguments.of( - (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceActivity(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), + (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceActivity(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), (BiConsumer>) (clusterServiceMock, queueCallbackCaptor) -> { var deviceActivityMsg = TransportProtos.DeviceActivityProto.newBuilder() .setTenantIdMSB(TENANT_ID.getId().getMostSignificantBits()) @@ -235,7 +244,7 @@ public class DefaultRuleEngineDeviceStateManagerTest { } ), Arguments.of( - (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceDisconnect(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), + (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceDisconnect(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), (BiConsumer>) (clusterServiceMock, queueCallbackCaptor) -> { var deviceDisconnectMsg = TransportProtos.DeviceDisconnectProto.newBuilder() .setTenantIdMSB(TENANT_ID.getId().getMostSignificantBits()) @@ -251,7 +260,7 @@ public class DefaultRuleEngineDeviceStateManagerTest { } ), Arguments.of( - (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceInactivity(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), + (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceInactivity(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), (BiConsumer>) (clusterServiceMock, queueCallbackCaptor) -> { var deviceInactivityMsg = TransportProtos.DeviceInactivityProto.newBuilder() .setTenantIdMSB(TENANT_ID.getId().getMostSignificantBits()) @@ -265,6 +274,22 @@ public class DefaultRuleEngineDeviceStateManagerTest { .build(); then(clusterServiceMock).should().pushMsgToCore(eq(EXTERNAL_TPI), any(UUID.class), eq(toCoreMsg), queueCallbackCaptor.capture()); } + ), + Arguments.of( + (BiConsumer) (deviceStateManager, tbCallbackMock) -> deviceStateManager.onDeviceInactivityTimeoutUpdate(TENANT_ID, DEVICE_ID, EVENT_TS, tbCallbackMock), + (BiConsumer>) (clusterServiceMock, queueCallbackCaptor) -> { + var deviceInactivityTimeoutUpdateMsg = TransportProtos.DeviceInactivityTimeoutUpdateProto.newBuilder() + .setTenantIdMSB(TENANT_ID.getId().getMostSignificantBits()) + .setTenantIdLSB(TENANT_ID.getId().getLeastSignificantBits()) + .setDeviceIdMSB(DEVICE_ID.getId().getMostSignificantBits()) + .setDeviceIdLSB(DEVICE_ID.getId().getLeastSignificantBits()) + .setInactivityTimeout(EVENT_TS) + .build(); + var toCoreMsg = TransportProtos.ToCoreMsg.newBuilder() + .setDeviceInactivityTimeoutUpdateMsg(deviceInactivityTimeoutUpdateMsg) + .build(); + then(clusterServiceMock).should().pushMsgToCore(eq(EXTERNAL_TPI), any(UUID.class), eq(toCoreMsg), queueCallbackCaptor.capture()); + } ) ); } diff --git a/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java index 26c978bbd0..26e913eacc 100644 --- a/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java @@ -38,9 +38,6 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.notification.rule.trigger.DeviceActivityTrigger; import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.query.EntityData; -import org.thingsboard.server.common.data.query.EntityKeyType; -import org.thingsboard.server.common.data.query.TsValue; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; @@ -72,7 +69,7 @@ import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; @@ -88,7 +85,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.thingsboard.server.service.state.DefaultDeviceStateService.ACTIVITY_STATE; import static org.thingsboard.server.service.state.DefaultDeviceStateService.INACTIVITY_ALARM_TIME; -import static org.thingsboard.server.service.state.DefaultDeviceStateService.INACTIVITY_TIMEOUT; import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_ACTIVITY_TIME; import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_CONNECT_TIME; import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_DISCONNECT_TIME; @@ -211,7 +207,7 @@ public class DefaultDeviceStateServiceTest { // THEN then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> - request.getTenantId().equals(TenantId.SYS_TENANT_ID) && request.getEntityId().equals(deviceId) && + request.getTenantId().equals(tenantId) && request.getEntityId().equals(deviceId) && request.getScope().equals(AttributeScope.SERVER_SCOPE) && request.getEntries().get(0).getKey().equals(LAST_CONNECT_TIME) && request.getEntries().get(0).getValue().equals(lastConnectTime) @@ -298,7 +294,7 @@ public class DefaultDeviceStateServiceTest { // THEN then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> - request.getTenantId().equals(TenantId.SYS_TENANT_ID) && request.getEntityId().equals(deviceId) && + request.getTenantId().equals(tenantId) && request.getEntityId().equals(deviceId) && request.getScope().equals(AttributeScope.SERVER_SCOPE) && request.getEntries().get(0).getKey().equals(LAST_DISCONNECT_TIME) && request.getEntries().get(0).getValue().equals(lastDisconnectTime) @@ -421,13 +417,13 @@ public class DefaultDeviceStateServiceTest { // THEN then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> - request.getTenantId().equals(TenantId.SYS_TENANT_ID) && request.getEntityId().equals(deviceId) && + request.getTenantId().equals(tenantId) && request.getEntityId().equals(deviceId) && request.getScope().equals(AttributeScope.SERVER_SCOPE) && request.getEntries().get(0).getKey().equals(INACTIVITY_ALARM_TIME) && request.getEntries().get(0).getValue().equals(lastInactivityTime) )); then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> - request.getTenantId().equals(TenantId.SYS_TENANT_ID) && request.getEntityId().equals(deviceId) && + request.getTenantId().equals(tenantId) && request.getEntityId().equals(deviceId) && request.getScope().equals(AttributeScope.SERVER_SCOPE) && request.getEntries().get(0).getKey().equals(ACTIVITY_STATE) && request.getEntries().get(0).getValue().equals(false) @@ -465,12 +461,12 @@ public class DefaultDeviceStateServiceTest { // THEN then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> - request.getTenantId().equals(TenantId.SYS_TENANT_ID) && request.getEntityId().equals(deviceId) && + request.getTenantId().equals(tenantId) && request.getEntityId().equals(deviceId) && request.getScope().equals(AttributeScope.SERVER_SCOPE) && request.getEntries().get(0).getKey().equals(INACTIVITY_ALARM_TIME) )); then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> - request.getTenantId().equals(TenantId.SYS_TENANT_ID) && request.getEntityId().equals(deviceId) && + request.getTenantId().equals(tenantId) && request.getEntityId().equals(deviceId) && request.getScope().equals(AttributeScope.SERVER_SCOPE) && request.getEntries().get(0).getKey().equals(ACTIVITY_STATE) && request.getEntries().get(0).getValue().equals(false) @@ -508,42 +504,6 @@ public class DefaultDeviceStateServiceTest { verify(service).fetchDeviceStateDataUsingSeparateRequests(deviceId); } - @Test - public void givenPersistToTelemetryAndDefaultInactivityTimeoutFetched_whenTransformingToDeviceStateData_thenTryGetInactivityFromAttribute() { - var defaultInactivityTimeoutInSec = 60L; - var latest = - Map.of( - EntityKeyType.TIME_SERIES, Map.of(INACTIVITY_TIMEOUT, new TsValue(0, Long.toString(defaultInactivityTimeoutInSec * 1000))), - EntityKeyType.SERVER_ATTRIBUTE, Map.of(INACTIVITY_TIMEOUT, new TsValue(0, Long.toString(5000L))) - ); - - process(latest, defaultInactivityTimeoutInSec); - } - - @Test - public void givenPersistToTelemetryAndNoInactivityTimeoutFetchedFromTimeSeries_whenTransformingToDeviceStateData_thenTryGetInactivityFromAttribute() { - var defaultInactivityTimeoutInSec = 60L; - var latest = - Map.of( - EntityKeyType.SERVER_ATTRIBUTE, Map.of(INACTIVITY_TIMEOUT, new TsValue(0, Long.toString(5000L))) - ); - - process(latest, defaultInactivityTimeoutInSec); - } - - private void process(Map> latest, long defaultInactivityTimeoutInSec) { - service.setDefaultInactivityTimeoutInSec(defaultInactivityTimeoutInSec); - service.setDefaultInactivityTimeoutMs(defaultInactivityTimeoutInSec * 1000); - service.setPersistToTelemetry(true); - - var deviceUuid = UUID.randomUUID(); - var deviceId = new DeviceId(deviceUuid); - - DeviceStateData deviceStateData = service.toDeviceStateData(new EntityData(deviceId, latest, Map.of()), new DeviceIdInfo(TenantId.SYS_TENANT_ID.getId(), UUID.randomUUID(), deviceUuid)); - - assertThat(deviceStateData.getState().getInactivityTimeout()).isEqualTo(5000L); - } - private void initStateService(long timeout) throws InterruptedException { service.stop(); reset(service, telemetrySubscriptionService); @@ -556,7 +516,7 @@ public class DefaultDeviceStateServiceTest { .thenReturn(new PageData<>(List.of(deviceIdInfo), 0, 1, false)); PartitionChangeEvent event = new PartitionChangeEvent(this, ServiceType.TB_CORE, Map.of( new QueueKey(ServiceType.TB_CORE), Collections.singleton(tpi) - )); + ), Collections.emptyMap()); service.onApplicationEvent(event); Thread.sleep(100); } @@ -1002,7 +962,7 @@ public class DefaultDeviceStateServiceTest { assertThat(actualNotification.isActive()).isFalse(); then(telemetrySubscriptionService).should().saveAttributes(argThat(request -> - request.getTenantId().equals(TenantId.SYS_TENANT_ID) && request.getEntityId().equals(deviceId) && + request.getTenantId().equals(tenantId) && request.getEntityId().equals(deviceId) && request.getScope().equals(AttributeScope.SERVER_SCOPE) && request.getEntries().get(0).getKey().equals(INACTIVITY_ALARM_TIME) && request.getEntries().get(0).getValue().equals(expectedLastInactivityAlarmTime) @@ -1114,7 +1074,7 @@ public class DefaultDeviceStateServiceTest { final long defaultTimeout = 1000; initStateService(defaultTimeout); given(deviceService.findDeviceById(any(TenantId.class), any(DeviceId.class))).willReturn(new Device(deviceId)); - given(attributesService.find(any(TenantId.class), any(EntityId.class), any(AttributeScope.class), anyList())).willReturn(Futures.immediateFuture(Collections.emptyList())); + given(attributesService.find(any(TenantId.class), any(EntityId.class), any(AttributeScope.class), anyCollection())).willReturn(Futures.immediateFuture(Collections.emptyList())); TransportProtos.DeviceStateServiceMsgProto proto = TransportProtos.DeviceStateServiceMsgProto.newBuilder() .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) @@ -1170,7 +1130,7 @@ public class DefaultDeviceStateServiceTest { assertThat(attributeRequestCaptor.getAllValues()).hasSize(2) .anySatisfy(request -> { - assertThat(request.getTenantId()).isEqualTo(TenantId.SYS_TENANT_ID); + assertThat(request.getTenantId()).isEqualTo(tenantId); assertThat(request.getEntityId()).isEqualTo(deviceId); assertThat(request.getScope()).isEqualTo(AttributeScope.SERVER_SCOPE); assertThat(request.getEntries()).singleElement().satisfies(attributeKvEntry -> { @@ -1179,7 +1139,7 @@ public class DefaultDeviceStateServiceTest { }); }) .anySatisfy(request -> { - assertThat(request.getTenantId()).isEqualTo(TenantId.SYS_TENANT_ID); + assertThat(request.getTenantId()).isEqualTo(tenantId); assertThat(request.getEntityId()).isEqualTo(deviceId); assertThat(request.getScope()).isEqualTo(AttributeScope.SERVER_SCOPE); assertThat(request.getEntries()).singleElement().satisfies(attributeKvEntry -> { @@ -1196,7 +1156,7 @@ public class DefaultDeviceStateServiceTest { final long defaultTimeout = 1000; initStateService(defaultTimeout); given(deviceService.findDeviceById(any(TenantId.class), any(DeviceId.class))).willReturn(new Device(deviceId)); - given(attributesService.find(any(TenantId.class), any(EntityId.class), any(AttributeScope.class), anyList())).willReturn(Futures.immediateFuture(Collections.emptyList())); + given(attributesService.find(any(TenantId.class), any(EntityId.class), any(AttributeScope.class), anyCollection())).willReturn(Futures.immediateFuture(Collections.emptyList())); long currentTime = System.currentTimeMillis(); DeviceState deviceState = DeviceState.builder() diff --git a/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java b/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java index 7b4d6923f0..da3b214afb 100644 --- a/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java @@ -43,6 +43,15 @@ import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; import org.thingsboard.server.common.data.device.data.DeviceData; import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration; @@ -70,6 +79,7 @@ import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.sync.ie.DeviceExportData; import org.thingsboard.server.common.data.sync.ie.EntityExportData; import org.thingsboard.server.common.data.sync.ie.EntityExportSettings; import org.thingsboard.server.common.data.sync.ie.EntityImportResult; @@ -79,6 +89,7 @@ import org.thingsboard.server.common.data.util.ThrowingRunnable; import org.thingsboard.server.controller.AbstractControllerTest; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.DeviceProfileService; @@ -145,6 +156,8 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { protected TenantService tenantService; @Autowired protected EntityViewService entityViewService; + @Autowired + protected CalculatedFieldService calculatedFieldService; protected TenantId tenantId1; protected User tenantAdmin1; @@ -191,6 +204,7 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { Asset asset = createAsset(tenantId1, null, assetProfile.getId(), "Asset 1"); DeviceProfile deviceProfile = createDeviceProfile(tenantId1, ruleChain.getId(), dashboard.getId(), "Device profile 1"); Device device = createDevice(tenantId1, null, deviceProfile.getId(), "Device 1"); + CalculatedField calculatedField = createCalculatedField(tenantId1, device.getId(), asset.getId()); Map entitiesExportData = Stream.of(customer.getId(), asset.getId(), device.getId(), ruleChain.getId(), dashboard.getId(), assetProfile.getId(), deviceProfile.getId()) @@ -198,6 +212,7 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { try { return exportEntity(tenantAdmin1, entityId, EntityExportSettings.builder() .exportCredentials(false) + .exportCalculatedFields(true) .build()); } catch (Exception e) { throw new RuntimeException(e); @@ -245,7 +260,6 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { verify(entityActionService, Mockito.never()).logEntityAction(any(), eq(importedAsset.getId()), eq(importedAsset), any(), eq(ActionType.UPDATED), isNull()); - EntityExportData updatedAssetEntity = getAndClone(entitiesExportData, EntityType.ASSET); updatedAssetEntity.getEntity().setLabel("t" + updatedAssetEntity.getEntity().getLabel()); Asset updatedAsset = importEntity(tenantAdmin2, updatedAssetEntity).getSavedEntity(); @@ -268,10 +282,27 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { importEntity(tenantAdmin2, getAndClone(entitiesExportData, EntityType.DEVICE)); verify(tbClusterService, Mockito.never()).onDeviceUpdated(eq(importedDevice), eq(importedDevice)); + // calculated field of imported device: + List calculatedFields = calculatedFieldService.findCalculatedFieldsByEntityId(tenantId2, importedDevice.getId()); + assertThat(calculatedFields.size()).isOne(); + var importedCalculatedField = calculatedFields.get(0); + assertThat(importedCalculatedField.getName()).isEqualTo(calculatedField.getName()); + verify(tbClusterService).onCalculatedFieldUpdated(eq(importedCalculatedField), isNull(), any()); + EntityExportData updatedDeviceEntity = getAndClone(entitiesExportData, EntityType.DEVICE); updatedDeviceEntity.getEntity().setLabel("t" + updatedDeviceEntity.getEntity().getLabel()); Device updatedDevice = importEntity(tenantAdmin2, updatedDeviceEntity).getSavedEntity(); verify(tbClusterService).onDeviceUpdated(eq(updatedDevice), eq(importedDevice)); + + // update calculated field: + DeviceExportData deviceExportData = (DeviceExportData) getAndClone(entitiesExportData, EntityType.DEVICE); + deviceExportData.setCalculatedFields(deviceExportData.getCalculatedFields().stream().peek(field -> field.setName("t_" + field.getName())).toList()); + importEntity(tenantAdmin2, deviceExportData).getSavedEntity(); + + calculatedFields = calculatedFieldService.findCalculatedFieldsByEntityId(tenantId2, importedDevice.getId()); + assertThat(calculatedFields.size()).isOne(); + importedCalculatedField = calculatedFields.get(0); + assertThat(importedCalculatedField.getName()).startsWith("t_"); } @Test @@ -290,12 +321,15 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { Device device = createDevice(tenantId1, customer.getId(), deviceProfile.getId(), "Device 1"); EntityView entityView = createEntityView(tenantId1, customer.getId(), device.getId(), "Entity view 1"); + CalculatedField calculatedField = createCalculatedField(tenantId1, device.getId(), device.getId()); + Map ids = new HashMap<>(); for (EntityId entityId : List.of(customer.getId(), ruleChain.getId(), dashboard.getId(), assetProfile.getId(), asset.getId(), deviceProfile.getId(), device.getId(), entityView.getId(), ruleChain.getId(), dashboard.getId())) { EntityExportData exportData = exportEntity(getSecurityUser(tenantAdmin1), entityId); EntityImportResult importResult = importEntity(getSecurityUser(tenantAdmin2), exportData, EntityImportSettings.builder() .saveCredentials(false) + .saveCalculatedFields(true) .build()); ids.put(entityId, (EntityId) importResult.getSavedEntity().getId()); } @@ -325,10 +359,16 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { assertThat(exportedDeviceProfile.getDefaultRuleChainId()).isEqualTo(ruleChain.getId()); assertThat(exportedDeviceProfile.getDefaultDashboardId()).isEqualTo(dashboard.getId()); - Device exportedDevice = (Device) exportEntity(tenantAdmin2, (DeviceId) ids.get(device.getId())).getEntity(); + EntityExportData entityExportData = exportEntity(tenantAdmin2, (DeviceId) ids.get(device.getId())); + Device exportedDevice = entityExportData.getEntity(); assertThat(exportedDevice.getCustomerId()).isEqualTo(customer.getId()); assertThat(exportedDevice.getDeviceProfileId()).isEqualTo(deviceProfile.getId()); + List calculatedFields = ((DeviceExportData) entityExportData).getCalculatedFields(); + assertThat(calculatedFields.size()).isOne(); + CalculatedField field = calculatedFields.get(0); + assertThat(field.getName()).isEqualTo(calculatedField.getName()); + EntityView exportedEntityView = (EntityView) exportEntity(tenantAdmin2, (EntityViewId) ids.get(entityView.getId())).getEntity(); assertThat(exportedEntityView.getCustomerId()).isEqualTo(customer.getId()); assertThat(exportedEntityView.getEntityId()).isEqualTo(device.getId()); @@ -340,7 +380,6 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { deviceProfileService.saveDeviceProfile(importedDeviceProfile); } - protected Device createDevice(TenantId tenantId, CustomerId customerId, DeviceProfileId deviceProfileId, String name) { Device device = new Device(); device.setTenantId(tenantId); @@ -549,9 +588,43 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { return relation; } + private CalculatedField createCalculatedField(TenantId tenantId, EntityId entityId, EntityId referencedEntityId) { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setTenantId(tenantId); + calculatedField.setEntityId(entityId); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("Test Calculated Field"); + calculatedField.setConfigurationVersion(1); + calculatedField.setConfiguration(getCalculatedFieldConfig(referencedEntityId)); + calculatedField.setVersion(1L); + return calculatedFieldService.save(calculatedField); + } + + private CalculatedFieldConfiguration getCalculatedFieldConfig(EntityId referencedEntityId) { + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + argument.setRefEntityId(referencedEntityId); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); + argument.setRefEntityKey(refEntityKey); + + config.setArguments(Map.of("T", argument)); + + config.setExpression("T - (100 - H) / 5"); + + Output output = new Output(); + output.setName("output"); + output.setType(OutputType.TIME_SERIES); + + config.setOutput(output); + + return config; + } + protected , I extends EntityId> EntityExportData exportEntity(User user, I entityId) throws Exception { return exportEntity(user, entityId, EntityExportSettings.builder() .exportCredentials(true) + .exportCalculatedFields(true) .build()); } @@ -562,6 +635,7 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { protected , I extends EntityId> EntityImportResult importEntity(User user, EntityExportData exportData) throws Exception { return importEntity(user, exportData, EntityImportSettings.builder() .saveCredentials(true) + .saveCalculatedFields(true) .build()); } diff --git a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java index 0088ae2b7b..06f61ca4e5 100644 --- a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java @@ -44,6 +44,15 @@ import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.debug.DebugSettings; import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; import org.thingsboard.server.common.data.device.data.DeviceData; @@ -562,6 +571,81 @@ public class VersionControlTest extends AbstractControllerTest { checkImportedEntity(tenantId1, defaultDeviceProfile, tenantId2, importedDeviceProfile); } + @Test + public void testVcWithCalculatedFields_betweenTenants() throws Exception { + Asset asset = createAsset(null, null, "Asset 1"); + Device device = createDevice(null, null, "Device 1", "test1"); + CalculatedField calculatedField = createCalculatedField("CalculatedField1", device.getId(), asset.getId()); + String versionId = createVersion("calculated fields of asset and device", EntityType.ASSET, EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE); + + loginTenant2(); + loadVersion(versionId, config -> { + config.setLoadCredentials(false); + }, EntityType.ASSET, EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE); + + Asset importedAsset = findAsset(asset.getName()); + Device importedDevice = findDevice(device.getName()); + checkImportedEntity(tenantId1, device, tenantId2, importedDevice); + checkImportedEntity(tenantId1, asset, tenantId2, importedAsset); + + List importedCalculatedFields = findCalculatedFieldsByEntityId(importedDevice.getId()); + assertThat(importedCalculatedFields).size().isOne(); + assertThat(importedCalculatedFields.get(0)).satisfies(importedField -> { + assertThat(importedField.getName()).isEqualTo(calculatedField.getName()); + assertThat(importedField.getType()).isEqualTo(calculatedField.getType()); + assertThat(importedField.getId()).isNotEqualTo(calculatedField.getId()); + }); + } + + @Test + public void testVcWithReferencedCalculatedFields_betweenTenants() throws Exception { + Asset asset = createAsset(null, null, "Asset 1"); + Device device = createDevice(null, null, "Device 1", "test1"); + CalculatedField deviceCalculatedField = createCalculatedField("CalculatedField1", device.getId(), asset.getId()); + CalculatedField assetCalculatedField = createCalculatedField("CalculatedField2", asset.getId(), device.getId()); + String versionId = createVersion("calculated fields of asset and device", EntityType.ASSET, EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE); + + loginTenant2(); + loadVersion(versionId, config -> { + config.setLoadCredentials(false); + }, EntityType.ASSET, EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE); + + Asset importedAsset = findAsset(asset.getName()); + Device importedDevice = findDevice(device.getName()); + checkImportedEntity(tenantId1, device, tenantId2, importedDevice); + checkImportedEntity(tenantId1, asset, tenantId2, importedAsset); + + List importedDeviceCalculatedFields = findCalculatedFieldsByEntityId(importedDevice.getId()); + assertThat(importedDeviceCalculatedFields).size().isOne(); + assertThat(importedDeviceCalculatedFields.get(0)).satisfies(importedField -> { + assertThat(importedField.getName()).isEqualTo(deviceCalculatedField.getName()); + assertThat(importedField.getType()).isEqualTo(deviceCalculatedField.getType()); + assertThat(importedField.getId()).isNotEqualTo(deviceCalculatedField.getId()); + }); + + List importedAssetCalculatedFields = findCalculatedFieldsByEntityId(importedAsset.getId()); + assertThat(importedAssetCalculatedFields).size().isOne(); + assertThat(importedAssetCalculatedFields.get(0)).satisfies(importedField -> { + assertThat(importedField.getName()).isEqualTo(assetCalculatedField.getName()); + assertThat(importedField.getType()).isEqualTo(assetCalculatedField.getType()); + assertThat(importedField.getId()).isNotEqualTo(assetCalculatedField.getId()); + }); + } + + @Test + public void testVcWithCalculatedFields_sameTenant() throws Exception { + Asset asset = createAsset(null, null, "Asset 1"); + CalculatedField calculatedField = createCalculatedField("CalculatedField", asset.getId(), asset.getId()); + String versionId = createVersion("asset and field", EntityType.ASSET); + + loadVersion(versionId, EntityType.ASSET); + CalculatedField importedCalculatedField = findCalculatedFieldByEntityId(asset.getId()); + assertThat(importedCalculatedField.getId()).isEqualTo(calculatedField.getId()); + assertThat(importedCalculatedField.getName()).isEqualTo(calculatedField.getName()); + assertThat(importedCalculatedField.getConfiguration()).isEqualTo(calculatedField.getConfiguration()); + assertThat(importedCalculatedField.getType()).isEqualTo(calculatedField.getType()); + } + private & HasTenantId> void checkImportedEntity(TenantId tenantId1, E initialEntity, TenantId tenantId2, E importedEntity) { assertThat(initialEntity.getTenantId()).isEqualTo(tenantId1); assertThat(importedEntity.getTenantId()).isEqualTo(tenantId2); @@ -626,10 +710,10 @@ public class VersionControlTest extends AbstractControllerTest { request.setEntityTypes(Arrays.stream(entityTypes).collect(Collectors.toMap(t -> t, entityType -> { EntityTypeVersionCreateConfig config = new EntityTypeVersionCreateConfig(); config.setAllEntities(true); - config.setSaveRelations(true); config.setSaveAttributes(true); config.setSaveCredentials(true); + config.setSaveCalculatedFields(true); return config; }))); @@ -695,6 +779,7 @@ public class VersionControlTest extends AbstractControllerTest { config.setLoadAttributes(true); config.setLoadRelations(true); config.setLoadCredentials(true); + config.setLoadCalculatedFields(true); config.setRemoveOtherEntities(false); config.setFindExistingEntityByName(true); configModifier.accept(config); @@ -941,6 +1026,38 @@ public class VersionControlTest extends AbstractControllerTest { return doPost("/api/v2/relation", relation, EntityRelation.class); } + private CalculatedField createCalculatedField(String name, EntityId entityId, EntityId referencedEntityId) { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(entityId); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName(name); + calculatedField.setConfigurationVersion(1); + calculatedField.setConfiguration(getCalculatedFieldConfig(referencedEntityId)); + calculatedField.setVersion(1L); + return doPost("/api/calculatedField", calculatedField, CalculatedField.class); + } + + private CalculatedFieldConfiguration getCalculatedFieldConfig(EntityId referencedEntityId) { + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + argument.setRefEntityId(referencedEntityId); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); + argument.setRefEntityKey(refEntityKey); + + config.setArguments(Map.of("T", argument)); + + config.setExpression("T - (100 - H) / 5"); + + Output output = new Output(); + output.setName("output"); + output.setType(OutputType.TIME_SERIES); + + config.setOutput(output); + + return config; + } + protected void checkImportedRuleChainData(RuleChain initialRuleChain, RuleChainMetaData initialMetaData, RuleChain importedRuleChain, RuleChainMetaData importedMetaData) { assertThat(importedRuleChain.getType()).isEqualTo(initialRuleChain.getType()); assertThat(importedRuleChain.getName()).isEqualTo(initialRuleChain.getName()); @@ -995,11 +1112,18 @@ public class VersionControlTest extends AbstractControllerTest { private RuleChain findRuleChain(String name) throws Exception { return doGetTypedWithPageLink("/api/ruleChains?", new TypeReference>() {}, new PageLink(100, 0, name)).getData().get(0); - } private RuleChainMetaData findRuleChainMetaData(RuleChainId ruleChainId) throws Exception { return doGet("/api/ruleChain/" + ruleChainId + "/metadata", RuleChainMetaData.class); } + private CalculatedField findCalculatedFieldByEntityId(EntityId entityId) throws Exception { + return doGetTypedWithPageLink("/api/" + entityId.getEntityType() + "/" + entityId.getId() + "/calculatedFields?", new TypeReference>() {}, new PageLink(100, 0)).getData().get(0); + } + + private List findCalculatedFieldsByEntityId(EntityId entityId) throws Exception { + return doGetTypedWithPageLink("/api/" + entityId.getEntityType() + "/" + entityId.getId() + "/calculatedFields?", new TypeReference>() {}, new PageLink(100, 0)).getData(); + } + } diff --git a/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java b/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java index dbd61b638e..64845a12cd 100644 --- a/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java @@ -18,37 +18,50 @@ package org.thingsboard.server.service.telemetry; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.SettableFuture; -import org.checkerframework.checker.nullness.qual.NonNull; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.rule.engine.api.AttributesDeleteRequest; +import org.thingsboard.rule.engine.api.AttributesSaveRequest; +import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.ApiUsageStateValue; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.id.ApiUsageStateId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.objects.AttributesEntityView; import org.thingsboard.server.common.data.objects.TelemetryEntityView; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.msg.rule.engine.DeviceAttributesEventNotificationMsg; import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.timeseries.TimeseriesService; @@ -56,6 +69,7 @@ import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.discovery.QueueKey; import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.service.cf.CalculatedFieldQueueService; import org.thingsboard.server.service.entitiy.entityview.TbEntityViewService; import org.thingsboard.server.service.subscription.SubscriptionManagerService; @@ -71,11 +85,17 @@ import java.util.concurrent.ExecutorService; import java.util.stream.LongStream; import java.util.stream.Stream; +import static com.google.common.util.concurrent.Futures.immediateFailedFuture; import static com.google.common.util.concurrent.Futures.immediateFuture; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) class DefaultTelemetrySubscriptionServiceTest { @@ -86,7 +106,7 @@ class DefaultTelemetrySubscriptionServiceTest { final long sampleTtl = 10_000L; - final List sampleTelemetry = List.of( + final List sampleTimeseries = List.of( new BasicTsKvEntry(100L, new DoubleDataEntry("temperature", 65.2)), new BasicTsKvEntry(100L, new DoubleDataEntry("humidity", 33.1)) ); @@ -98,14 +118,6 @@ class DefaultTelemetrySubscriptionServiceTest { .myPartition(true) .build(); - final FutureCallback emptyCallback = new FutureCallback<>() { - @Override - public void onSuccess(Void result) {} - - @Override - public void onFailure(@NonNull Throwable t) {} - }; - ExecutorService wsCallBackExecutor; ExecutorService tsCallBackExecutor; @@ -125,12 +137,16 @@ class DefaultTelemetrySubscriptionServiceTest { TbApiUsageReportClient apiUsageClient; @Mock TbApiUsageStateService apiUsageStateService; + @Mock + CalculatedFieldQueueService calculatedFieldQueueService; + @Mock + DeviceStateManager deviceStateManager; DefaultTelemetrySubscriptionService telemetryService; @BeforeEach void setup() { - telemetryService = new DefaultTelemetrySubscriptionService(attrService, tsService, tbEntityViewService, apiUsageClient, apiUsageStateService); + telemetryService = new DefaultTelemetrySubscriptionService(attrService, tsService, tbEntityViewService, apiUsageClient, apiUsageStateService, calculatedFieldQueueService, deviceStateManager); ReflectionTestUtils.setField(telemetryService, "clusterService", clusterService); ReflectionTestUtils.setField(telemetryService, "partitionService", partitionService); ReflectionTestUtils.setField(telemetryService, "subscriptionManagerService", Optional.of(subscriptionManagerService)); @@ -147,15 +163,22 @@ class DefaultTelemetrySubscriptionServiceTest { lenient().when(partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId)).thenReturn(tpi); - lenient().when(tsService.save(tenantId, entityId, sampleTelemetry, sampleTtl)).thenReturn(immediateFuture(sampleTelemetry.size())); - lenient().when(tsService.saveWithoutLatest(tenantId, entityId, sampleTelemetry, sampleTtl)).thenReturn(immediateFuture(sampleTelemetry.size())); - lenient().when(tsService.saveLatest(tenantId, entityId, sampleTelemetry)).thenReturn(immediateFuture(listOfNNumbers(sampleTelemetry.size()))); + lenient().when(tsService.save(tenantId, entityId, sampleTimeseries, sampleTtl)).thenReturn(immediateFuture(TimeseriesSaveResult.of(sampleTimeseries.size(), listOfNNumbers(sampleTimeseries.size())))); + lenient().when(tsService.saveWithoutLatest(tenantId, entityId, sampleTimeseries, sampleTtl)).thenReturn(immediateFuture(TimeseriesSaveResult.of(sampleTimeseries.size(), null))); + lenient().when(tsService.saveLatest(tenantId, entityId, sampleTimeseries)).thenReturn(immediateFuture(TimeseriesSaveResult.of(sampleTimeseries.size(), listOfNNumbers(sampleTimeseries.size())))); // mock no entity views lenient().when(tbEntityViewService.findEntityViewsByTenantIdAndEntityIdAsync(tenantId, entityId)).thenReturn(immediateFuture(Collections.emptyList())); + // mock that calls to CF queue service are always successful + lenient().doAnswer(inv -> { + FutureCallback callback = inv.getArgument(2); + callback.onSuccess(null); + return null; + }).when(calculatedFieldQueueService).pushRequestToQueue(any(TimeseriesSaveRequest.class), any(), any()); + // send partition change event so currentPartitions set is populated - telemetryService.onTbApplicationEvent(new PartitionChangeEvent(this, ServiceType.TB_CORE, Map.of(new QueueKey(ServiceType.TB_CORE), Set.of(tpi)))); + telemetryService.onTbApplicationEvent(new PartitionChangeEvent(this, ServiceType.TB_CORE, Map.of(new QueueKey(ServiceType.TB_CORE), Set.of(tpi)), Collections.emptyMap())); } @AfterEach @@ -164,6 +187,28 @@ class DefaultTelemetrySubscriptionServiceTest { tsCallBackExecutor.shutdownNow(); } + /* --- Save time series API --- */ + + @Test + void shouldThrowErrorWhenTryingToSaveTimeseriesForApiUsageState() { + // GIVEN + var request = TimeseriesSaveRequest.builder() + .tenantId(tenantId) + .customerId(customerId) + .entityId(new ApiUsageStateId(UUID.randomUUID())) + .entries(sampleTimeseries) + .strategy(TimeseriesSaveRequest.Strategy.PROCESS_ALL) + .build(); + + // WHEN + assertThatThrownBy(() -> telemetryService.saveTimeseries(request)) + .isInstanceOf(RuntimeException.class) + .hasMessage("Can't update API Usage State!"); + + // THEN + then(tsService).shouldHaveNoInteractions(); + } + @Test void shouldReportStorageDataPointsApiUsageWhenTimeSeriesIsSaved() { // GIVEN @@ -171,17 +216,16 @@ class DefaultTelemetrySubscriptionServiceTest { .tenantId(tenantId) .customerId(customerId) .entityId(entityId) - .entries(sampleTelemetry) + .entries(sampleTimeseries) .ttl(sampleTtl) - .strategy(new TimeseriesSaveRequest.Strategy(true, false, false)) - .callback(emptyCallback) + .strategy(new TimeseriesSaveRequest.Strategy(true, false, false, false)) .build(); // WHEN telemetryService.saveTimeseries(request); // THEN - then(apiUsageClient).should().report(tenantId, customerId, ApiUsageRecordKey.STORAGE_DP_COUNT, sampleTelemetry.size()); + then(apiUsageClient).should().report(tenantId, customerId, ApiUsageRecordKey.STORAGE_DP_COUNT, sampleTimeseries.size()); } @Test @@ -191,10 +235,9 @@ class DefaultTelemetrySubscriptionServiceTest { .tenantId(tenantId) .customerId(customerId) .entityId(entityId) - .entries(sampleTelemetry) + .entries(sampleTimeseries) .ttl(sampleTtl) .strategy(TimeseriesSaveRequest.Strategy.LATEST_AND_WS) - .callback(emptyCallback) .build(); // WHEN @@ -214,9 +257,9 @@ class DefaultTelemetrySubscriptionServiceTest { .tenantId(tenantId) .customerId(customerId) .entityId(entityId) - .entries(sampleTelemetry) + .entries(sampleTimeseries) .ttl(sampleTtl) - .strategy(TimeseriesSaveRequest.Strategy.SAVE_ALL) + .strategy(TimeseriesSaveRequest.Strategy.PROCESS_ALL) .future(future) .build(); @@ -240,7 +283,7 @@ class DefaultTelemetrySubscriptionServiceTest { .tenantId(tenantId) .customerId(customerId) .entityId(entityId) - .entries(sampleTelemetry) + .entries(sampleTimeseries) .ttl(sampleTtl) .strategy(TimeseriesSaveRequest.Strategy.LATEST_AND_WS) .future(future) @@ -260,12 +303,12 @@ class DefaultTelemetrySubscriptionServiceTest { entityView.setTenantId(tenantId); entityView.setCustomerId(customerId); entityView.setEntityId(entityId); - entityView.setKeys(new TelemetryEntityView(sampleTelemetry.stream().map(KvEntry::getKey).toList(), new AttributesEntityView())); + entityView.setKeys(new TelemetryEntityView(sampleTimeseries.stream().map(KvEntry::getKey).toList(), new AttributesEntityView())); // mock that there is one entity view given(tbEntityViewService.findEntityViewsByTenantIdAndEntityIdAsync(tenantId, entityId)).willReturn(immediateFuture(List.of(entityView))); // mock that save latest call for entity view is successful - given(tsService.saveLatest(tenantId, entityView.getId(), sampleTelemetry)).willReturn(immediateFuture(listOfNNumbers(sampleTelemetry.size()))); + given(tsService.saveLatest(tenantId, entityView.getId(), sampleTimeseries)).willReturn(immediateFuture(TimeseriesSaveResult.of(sampleTimeseries.size(), listOfNNumbers(sampleTimeseries.size())))); // mock TPI for entity view given(partitionService.resolve(ServiceType.TB_CORE, tenantId, entityView.getId())).willReturn(tpi); @@ -273,10 +316,9 @@ class DefaultTelemetrySubscriptionServiceTest { .tenantId(tenantId) .customerId(customerId) .entityId(entityId) - .entries(sampleTelemetry) + .entries(sampleTimeseries) .ttl(sampleTtl) - .strategy(new TimeseriesSaveRequest.Strategy(false, true, false)) - .callback(emptyCallback) + .strategy(new TimeseriesSaveRequest.Strategy(false, true, false, false)) .build(); // WHEN @@ -284,12 +326,12 @@ class DefaultTelemetrySubscriptionServiceTest { // THEN // should save latest to both the main entity and it's entity view - then(tsService).should().saveLatest(tenantId, entityId, sampleTelemetry); - then(tsService).should().saveLatest(tenantId, entityView.getId(), sampleTelemetry); + then(tsService).should().saveLatest(tenantId, entityId, sampleTimeseries); + then(tsService).should().saveLatest(tenantId, entityView.getId(), sampleTimeseries); then(tsService).shouldHaveNoMoreInteractions(); // should send WS update only for entity view (WS update for the main entity is disabled in the save request) - then(subscriptionManagerService).should().onTimeSeriesUpdate(tenantId, entityView.getId(), sampleTelemetry, TbCallback.EMPTY); + then(subscriptionManagerService).should().onTimeSeriesUpdate(tenantId, entityView.getId(), sampleTimeseries, TbCallback.EMPTY); then(subscriptionManagerService).shouldHaveNoMoreInteractions(); } @@ -300,10 +342,9 @@ class DefaultTelemetrySubscriptionServiceTest { .tenantId(tenantId) .customerId(customerId) .entityId(entityId) - .entries(sampleTelemetry) + .entries(sampleTimeseries) .ttl(sampleTtl) - .strategy(new TimeseriesSaveRequest.Strategy(true, false, false)) - .callback(emptyCallback) + .strategy(new TimeseriesSaveRequest.Strategy(true, false, false, false)) .build(); // WHEN @@ -311,7 +352,7 @@ class DefaultTelemetrySubscriptionServiceTest { // THEN // should save only time series for the main entity - then(tsService).should().saveWithoutLatest(tenantId, entityId, sampleTelemetry, sampleTtl); + then(tsService).should().saveWithoutLatest(tenantId, entityId, sampleTimeseries, sampleTtl); then(tsService).shouldHaveNoMoreInteractions(); // should not send any WS updates @@ -319,17 +360,16 @@ class DefaultTelemetrySubscriptionServiceTest { } @ParameterizedTest - @MethodSource("booleanCombinations") - void shouldCallCorrectApiBasedOnBooleanFlagsInTheSaveRequest(boolean saveTimeseries, boolean saveLatest, boolean sendWsUpdate) { + @MethodSource("allCombinationsOfFourBooleans") + void shouldCallCorrectSaveTimeseriesApiBasedOnBooleanFlagsInTheSaveRequest(boolean saveTimeseries, boolean saveLatest, boolean sendWsUpdate, boolean processCalculatedFields) { // GIVEN var request = TimeseriesSaveRequest.builder() .tenantId(tenantId) .customerId(customerId) .entityId(entityId) - .entries(sampleTelemetry) + .entries(sampleTimeseries) .ttl(sampleTtl) - .strategy(new TimeseriesSaveRequest.Strategy(saveTimeseries, saveLatest, sendWsUpdate)) - .callback(emptyCallback) + .strategy(new TimeseriesSaveRequest.Strategy(saveTimeseries, saveLatest, sendWsUpdate, processCalculatedFields)) .build(); // WHEN @@ -337,22 +377,86 @@ class DefaultTelemetrySubscriptionServiceTest { // THEN if (saveTimeseries && saveLatest) { - then(tsService).should().save(tenantId, entityId, sampleTelemetry, sampleTtl); + then(tsService).should().save(tenantId, entityId, sampleTimeseries, sampleTtl); } else if (saveLatest) { - then(tsService).should().saveLatest(tenantId, entityId, sampleTelemetry); + then(tsService).should().saveLatest(tenantId, entityId, sampleTimeseries); } else if (saveTimeseries) { - then(tsService).should().saveWithoutLatest(tenantId, entityId, sampleTelemetry, sampleTtl); + then(tsService).should().saveWithoutLatest(tenantId, entityId, sampleTimeseries, sampleTtl); + } + + if (processCalculatedFields) { + then(calculatedFieldQueueService).should().pushRequestToQueue(eq(request), any(), eq(request.getCallback())); } + then(tsService).shouldHaveNoMoreInteractions(); if (sendWsUpdate) { - then(subscriptionManagerService).should().onTimeSeriesUpdate(tenantId, entityId, sampleTelemetry, TbCallback.EMPTY); + then(subscriptionManagerService).should().onTimeSeriesUpdate(tenantId, entityId, sampleTimeseries, TbCallback.EMPTY); + } else { + then(subscriptionManagerService).shouldHaveNoInteractions(); + } + } + + private static Stream allCombinationsOfFourBooleans() { + return Stream.of( + Arguments.of(true, true, true, true), + Arguments.of(true, true, true, false), + Arguments.of(true, true, false, true), + Arguments.of(true, true, false, false), + Arguments.of(true, false, true, true), + Arguments.of(true, false, true, false), + Arguments.of(true, false, false, true), + Arguments.of(true, false, false, false), + Arguments.of(false, true, true, true), + Arguments.of(false, true, true, false), + Arguments.of(false, true, false, true), + Arguments.of(false, true, false, false), + Arguments.of(false, false, true, true), + Arguments.of(false, false, true, false), + Arguments.of(false, false, false, true), + Arguments.of(false, false, false, false) + ); + } + + /* --- Save attributes API --- */ + + @ParameterizedTest + @MethodSource("allCombinationsOfThreeBooleans") + void shouldCallCorrectSaveAttributesApiBasedOnBooleanFlagsInTheSaveRequest(boolean saveAttributes, boolean sendWsUpdate, boolean processCalculatedFields) { + // GIVEN + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(entityId) + .scope(AttributeScope.SERVER_SCOPE) + .entry(new DoubleDataEntry("temperature", 65.2)) + .notifyDevice(false) + .strategy(new AttributesSaveRequest.Strategy(saveAttributes, sendWsUpdate, processCalculatedFields)) + .build(); + + lenient().when(attrService.save(tenantId, entityId, request.getScope(), request.getEntries())).thenReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + if (saveAttributes) { + then(attrService).should().save(tenantId, entityId, request.getScope(), request.getEntries()); + } else { + then(attrService).shouldHaveNoInteractions(); + } + + if (processCalculatedFields) { + then(calculatedFieldQueueService).should().pushRequestToQueue(eq(request), any(), eq(request.getCallback())); + } + + if (sendWsUpdate) { + then(subscriptionManagerService).should().onAttributesUpdate(tenantId, entityId, request.getScope().name(), request.getEntries(), TbCallback.EMPTY); } else { then(subscriptionManagerService).shouldHaveNoInteractions(); } } - private static Stream booleanCombinations() { + static Stream allCombinationsOfThreeBooleans() { return Stream.of( Arguments.of(true, true, true), Arguments.of(true, true, false), @@ -365,7 +469,642 @@ class DefaultTelemetrySubscriptionServiceTest { ); } - // used to emulate sequence numbers returned by save latest API + @Test + void shouldThrowErrorWhenTryingToSaveAttributesForApiUsageState() { + // GIVEN + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(new ApiUsageStateId(UUID.randomUUID())) + .scope(AttributeScope.SHARED_SCOPE) + .entry(new DoubleDataEntry("temperature", 65.2)) + .notifyDevice(true) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + // WHEN + assertThatThrownBy(() -> telemetryService.saveAttributes(request)) + .isInstanceOf(RuntimeException.class) + .hasMessage("Can't update API Usage State!"); + + // THEN + then(attrService).shouldHaveNoInteractions(); + } + + @Test + void shouldSendAttributesUpdateNotificationWhenDeviceSharedAttributesAreSavedAndNotifyDeviceIsTrue() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List entries = List.of( + new BaseAttributeKvEntry(123L, new DoubleDataEntry("shared1", 65.2)), + new BaseAttributeKvEntry(456L, new StringDataEntry("shared2", "test")) + ); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SHARED_SCOPE) + .entries(entries) + .notifyDevice(true) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, deviceId, request.getScope(), entries)).willReturn(immediateFuture(listOfNNumbers(entries.size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + var expectedAttributesUpdateMsg = DeviceAttributesEventNotificationMsg.onUpdate(tenantId, deviceId, "SHARED_SCOPE", entries); + + then(clusterService).should().pushMsgToCore(eq(expectedAttributesUpdateMsg), isNull()); + } + + @ParameterizedTest + @EnumSource( + value = EntityType.class, + names = {"DEVICE", "API_USAGE_STATE"}, // API usage state excluded due to coverage in another test + mode = EnumSource.Mode.EXCLUDE + ) + void shouldNotSendAttributesUpdateNotificationWhenEntityIsNotDevice(EntityType entityType) { + // GIVEN + var nonDeviceId = EntityIdFactory.getByTypeAndUuid(entityType, "cc51e450-53e1-11ee-883e-e56b48fd2088"); + List entries = List.of( + new BaseAttributeKvEntry(123L, new DoubleDataEntry("shared1", 65.2)), + new BaseAttributeKvEntry(456L, new StringDataEntry("shared2", "test")) + ); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(nonDeviceId) + .scope(AttributeScope.SHARED_SCOPE) + .entries(entries) + .notifyDevice(true) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, nonDeviceId, request.getScope(), entries)).willReturn(immediateFuture(listOfNNumbers(entries.size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(clusterService).should(never()).pushMsgToCore(any(), any()); + } + + @ParameterizedTest + @EnumSource( + value = AttributeScope.class, + names = "SHARED_SCOPE", + mode = EnumSource.Mode.EXCLUDE + ) + void shouldNotSendAttributesUpdateNotificationWhenAttributesAreNotShared(AttributeScope notSharedScope) { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List entries = List.of( + new BaseAttributeKvEntry(123L, new DoubleDataEntry("shared1", 65.2)), + new BaseAttributeKvEntry(456L, new StringDataEntry("shared2", "test")) + ); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(notSharedScope) + .entries(entries) + .notifyDevice(true) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, deviceId, request.getScope(), entries)).willReturn(immediateFuture(listOfNNumbers(entries.size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(clusterService).should(never()).pushMsgToCore(any(), any()); + } + + @Test + void shouldNotSendAttributesUpdateNotificationWhenNotifyDeviceIsFalse() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List entries = List.of( + new BaseAttributeKvEntry(123L, new DoubleDataEntry("shared1", 65.2)), + new BaseAttributeKvEntry(456L, new StringDataEntry("shared2", "test")) + ); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SHARED_SCOPE) + .entries(entries) + .notifyDevice(false) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, deviceId, request.getScope(), entries)).willReturn(immediateFuture(listOfNNumbers(entries.size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(clusterService).should(never()).pushMsgToCore(any(), any()); + } + + @Test + void shouldNotSendAttributesUpdateNotificationWhenAttributesSaveWasSkipped() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List entries = List.of( + new BaseAttributeKvEntry(123L, new DoubleDataEntry("shared1", 65.2)), + new BaseAttributeKvEntry(456L, new StringDataEntry("shared2", "test")) + ); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SHARED_SCOPE) + .entries(entries) + .notifyDevice(true) + .strategy(new AttributesSaveRequest.Strategy(false, false, false)) + .build(); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(clusterService).should(never()).pushMsgToCore(any(), any()); + } + + @Test + void shouldNotSendAttributesUpdateNotificationWhenAttributesSaveFailed() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List entries = List.of( + new BaseAttributeKvEntry(123L, new DoubleDataEntry("shared1", 65.2)), + new BaseAttributeKvEntry(456L, new StringDataEntry("shared2", "test")) + ); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SHARED_SCOPE) + .entries(entries) + .notifyDevice(true) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, deviceId, request.getScope(), entries)).willReturn(immediateFailedFuture(new RuntimeException("failed to save"))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(clusterService).should(never()).pushMsgToCore(any(), any()); + } + + @Test + void shouldNotifyDeviceStateManagerWhenDeviceInactivityTimeoutWasUpdated() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + var inactivityTimeout = new BaseAttributeKvEntry(123L, new LongDataEntry("inactivityTimeout", 5000L)); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SERVER_SCOPE) + .entry(inactivityTimeout) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(deviceStateManager).should().onDeviceInactivityTimeoutUpdate(tenantId, deviceId, 5000L, TbCallback.EMPTY); + } + + @Test + void shouldNotNotifyDeviceStateManagerWhenDeviceInactivityTimeoutSaveWasSkipped() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + var inactivityTimeout = new BaseAttributeKvEntry(123L, new LongDataEntry("inactivityTimeout", 5000L)); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SERVER_SCOPE) + .entry(inactivityTimeout) + .strategy(new AttributesSaveRequest.Strategy(false, true, true)) + .build(); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(deviceStateManager).shouldHaveNoInteractions(); + } + + @ParameterizedTest + @EnumSource( + value = EntityType.class, + names = {"DEVICE", "API_USAGE_STATE"}, // API usage state excluded due to coverage in another test + mode = EnumSource.Mode.EXCLUDE + ) + void shouldNotNotifyDeviceStateManagerWhenInactivityTimeoutWasUpdatedButEntityTypeIsNotDevice(EntityType entityType) { + // GIVEN + var nonDeviceId = EntityIdFactory.getByTypeAndUuid(entityType, "cc51e450-53e1-11ee-883e-e56b48fd2088"); + var inactivityTimeout = new BaseAttributeKvEntry(123L, new LongDataEntry("inactivityTimeout", 5000L)); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(nonDeviceId) + .scope(AttributeScope.SERVER_SCOPE) + .entry(inactivityTimeout) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, nonDeviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(deviceStateManager).shouldHaveNoInteractions(); + } + + @ParameterizedTest + @EnumSource( + value = AttributeScope.class, + names = {"SERVER_SCOPE"}, + mode = EnumSource.Mode.EXCLUDE + ) + void shouldNotNotifyDeviceStateManagerWhenInactivityTimeoutWasUpdatedButAttributeScopeIsNotServer(AttributeScope nonServerScope) { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + var inactivityTimeout = new BaseAttributeKvEntry(123L, new LongDataEntry("inactivityTimeout", 5000L)); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(nonServerScope) + .entry(inactivityTimeout) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(deviceStateManager).shouldHaveNoInteractions(); + } + + @Test + void shouldNotNotifyDeviceStateManagerWhenUpdatedAttributesDoNotContainInactivityTimeout() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + var inactivityTimeout = new BaseAttributeKvEntry(123L, new LongDataEntry("notInactivityTimeout", 5000L)); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SERVER_SCOPE) + .entry(inactivityTimeout) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(deviceStateManager).shouldHaveNoInteractions(); + } + + @Test + void shouldUseInactivityTimeoutEntryWithTheGreatestVersion() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List entries = List.of( + new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 0L), 0L, null), + new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 1000L), 3L, 1L), + new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 2000L), 2L, 2L), + new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 3000L), 1L, 3L) + ); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SERVER_SCOPE) + .entries(entries) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(deviceStateManager).should().onDeviceInactivityTimeoutUpdate(tenantId, deviceId, 3000L, TbCallback.EMPTY); + } + + @Test + void shouldUseInactivityTimeoutEntryWithTheGreatestLastUpdateTsWhenVersionsAreTheSame() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List entries = List.of( + new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 1000L), 1L, 1L), + new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 2000L), 2L, 1L), + new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 3000L), 3L, 1L) + ); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SERVER_SCOPE) + .entries(entries) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(deviceStateManager).should().onDeviceInactivityTimeoutUpdate(tenantId, deviceId, 3000L, TbCallback.EMPTY); + } + + /* --- Delete attributes API --- */ + + @Test + void shouldThrowErrorWhenTryingToDeleteAttributesForApiUsageState() { + // GIVEN + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(new ApiUsageStateId(UUID.randomUUID())) + .scope(AttributeScope.SHARED_SCOPE) + .keys(List.of("attributeKeyToDelete1", "attributeKeyToDelete2")) + .notifyDevice(true) + .build(); + + // WHEN + assertThatThrownBy(() -> telemetryService.deleteAttributes(request)) + .isInstanceOf(RuntimeException.class) + .hasMessage("Can't update API Usage State!"); + + // THEN + then(attrService).shouldHaveNoInteractions(); + } + + @Test + void shouldSendAttributesDeletedNotificationWhenDeviceSharedAttributesAreDeletedAndNotifyDeviceIsTrue() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List keys = List.of("attributeKeyToDelete1", "attributeKeyToDelete2"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SHARED_SCOPE) + .keys(keys) + .notifyDevice(true) + .build(); + + given(attrService.removeAll(tenantId, deviceId, request.getScope(), keys)).willReturn(immediateFuture(keys)); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + var expectedAttributesDeletedMsg = DeviceAttributesEventNotificationMsg.onDelete(tenantId, deviceId, "SHARED_SCOPE", List.of("attributeKeyToDelete1", "attributeKeyToDelete2")); + + then(clusterService).should().pushMsgToCore(eq(expectedAttributesDeletedMsg), isNull()); + } + + @ParameterizedTest + @EnumSource( + value = EntityType.class, + names = {"DEVICE", "API_USAGE_STATE"}, // API usage state excluded due to coverage in another test + mode = EnumSource.Mode.EXCLUDE + ) + void shouldNotSendAttributesDeletedNotificationWhenEntityIsNotDevice(EntityType entityType) { + // GIVEN + var nonDeviceId = EntityIdFactory.getByTypeAndUuid(entityType, "cc51e450-53e1-11ee-883e-e56b48fd2088"); + List keys = List.of("attributeKeyToDelete1", "attributeKeyToDelete2"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(nonDeviceId) + .scope(AttributeScope.SHARED_SCOPE) + .keys(keys) + .notifyDevice(true) + .build(); + + given(attrService.removeAll(tenantId, nonDeviceId, request.getScope(), keys)).willReturn(immediateFuture(keys)); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + then(clusterService).should(never()).pushMsgToCore(any(), any()); + } + + @ParameterizedTest + @EnumSource( + value = AttributeScope.class, + names = "SHARED_SCOPE", + mode = EnumSource.Mode.EXCLUDE + ) + void shouldNotSendAttributesDeletedNotificationWhenAttributesAreNotShared(AttributeScope notSharedScope) { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List keys = List.of("attributeKeyToDelete1", "attributeKeyToDelete2"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(notSharedScope) + .keys(keys) + .notifyDevice(true) + .build(); + + given(attrService.removeAll(tenantId, deviceId, request.getScope(), keys)).willReturn(immediateFuture(keys)); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + then(clusterService).should(never()).pushMsgToCore(any(), any()); + } + + @Test + void shouldNotSendAttributesDeletedNotificationWhenNotifyDeviceIsFalse() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List keys = List.of("attributeKeyToDelete1", "attributeKeyToDelete2"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SHARED_SCOPE) + .keys(keys) + .notifyDevice(false) + .build(); + + given(attrService.removeAll(tenantId, deviceId, request.getScope(), keys)).willReturn(immediateFuture(keys)); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + then(clusterService).should(never()).pushMsgToCore(any(), any()); + } + + @Test + void shouldNotSendAttributesDeletedNotificationWhenAttributesDeleteFailed() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List keys = List.of("attributeKeyToDelete1", "attributeKeyToDelete2"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SHARED_SCOPE) + .keys(keys) + .notifyDevice(true) + .build(); + + given(attrService.removeAll(tenantId, deviceId, request.getScope(), keys)).willReturn(immediateFailedFuture(new RuntimeException("failed to delete"))); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + then(clusterService).should(never()).pushMsgToCore(any(), any()); + } + + @Test + void shouldNotifyDeviceStateManagerWhenDeviceInactivityTimeoutWasDeleted() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SERVER_SCOPE) + .keys(List.of("inactivityTimeout", "someOtherDeletedAttribute")) + .build(); + + given(attrService.removeAll(tenantId, deviceId, request.getScope(), request.getKeys())).willReturn(immediateFuture(request.getKeys())); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + then(deviceStateManager).should().onDeviceInactivityTimeoutUpdate(tenantId, deviceId, 0L, TbCallback.EMPTY); + } + + @ParameterizedTest + @EnumSource( + value = EntityType.class, + names = {"DEVICE", "API_USAGE_STATE"}, // API usage state excluded due to coverage in another test + mode = EnumSource.Mode.EXCLUDE + ) + void shouldNotNotifyDeviceStateManagerWhenInactivityTimeoutWasDeletedButEntityTypeIsNotDevice(EntityType entityType) { + // GIVEN + var nonDeviceId = EntityIdFactory.getByTypeAndUuid(entityType, "cc51e450-53e1-11ee-883e-e56b48fd2088"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(nonDeviceId) + .scope(AttributeScope.SERVER_SCOPE) + .keys(List.of("inactivityTimeout", "someOtherDeletedAttribute")) + .build(); + + given(attrService.removeAll(tenantId, nonDeviceId, request.getScope(), request.getKeys())).willReturn(immediateFuture(request.getKeys())); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + then(deviceStateManager).shouldHaveNoInteractions(); + } + + @ParameterizedTest + @EnumSource( + value = AttributeScope.class, + names = {"SERVER_SCOPE"}, + mode = EnumSource.Mode.EXCLUDE + ) + void shouldNotNotifyDeviceStateManagerWhenInactivityTimeoutWasDeletedButAttributeScopeIsNotServer(AttributeScope nonServerScope) { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(nonServerScope) + .keys(List.of("inactivityTimeout", "someOtherDeletedAttribute")) + .build(); + + given(attrService.removeAll(tenantId, deviceId, request.getScope(), request.getKeys())).willReturn(immediateFuture(request.getKeys())); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + then(deviceStateManager).shouldHaveNoInteractions(); + } + + @Test + void shouldNotNotifyDeviceStateManagerWhenInactivityTimeoutWasNotDeleted() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SERVER_SCOPE) + .keys(List.of("someOtherDeletedAttribute")) + .build(); + + given(attrService.removeAll(tenantId, deviceId, request.getScope(), request.getKeys())).willReturn(immediateFuture(request.getKeys())); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + then(deviceStateManager).shouldHaveNoInteractions(); + } + + @Test + void shouldNotNotifyDeviceStateManagerWhenDeviceInactivityTimeoutDeleteFailed() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SERVER_SCOPE) + .keys(List.of("inactivityTimeout", "someOtherDeletedAttribute")) + .build(); + + given(attrService.removeAll(tenantId, deviceId, request.getScope(), request.getKeys())).willReturn(immediateFailedFuture(new RuntimeException("failed to delete"))); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + then(deviceStateManager).shouldHaveNoInteractions(); + } + + // used to emulate versions returned by save APIs private static List listOfNNumbers(int N) { return LongStream.range(0, N).boxed().toList(); } diff --git a/application/src/test/resources/application-test.properties b/application/src/test/resources/application-test.properties index a5cc50f5d1..b8bdcf67e6 100644 --- a/application/src/test/resources/application-test.properties +++ b/application/src/test/resources/application-test.properties @@ -35,6 +35,8 @@ sql.events.batch_threads=2 actors.system.tenant_dispatcher_pool_size=4 actors.system.device_dispatcher_pool_size=8 actors.system.rule_dispatcher_pool_size=12 +actors.system.cfm_dispatcher_pool_size=2 +actors.system.cfe_dispatcher_pool_size=2 transport.sessions.report_timeout=10000 queue.transport_api.request_poll_interval=5 queue.transport_api.response_poll_interval=5 @@ -56,3 +58,6 @@ sql.ttl.edge_events.edge_event_ttl=2592000 server.log_controller_error_stack_trace=false transport.gateway.dashboard.sync.enabled=false + +queue.edqs.sync.enabled=false +queue.edqs.api.supported=false diff --git a/application/src/test/resources/update/330/device_profile_001_out.json b/application/src/test/resources/update/330/device_profile_001_out.json index 9a349c6638..29e2241ee9 100644 --- a/application/src/test/resources/update/330/device_profile_001_out.json +++ b/application/src/test/resources/update/330/device_profile_001_out.json @@ -64,7 +64,8 @@ "dynamicValue": { "sourceType": null, "sourceAttribute": null, - "inherit": false + "inherit": false, + "resolvedValue" : null } } } @@ -103,7 +104,8 @@ "dynamicValue": { "sourceType": null, "sourceAttribute": null, - "inherit": false + "inherit": false, + "resolvedValue" : null } } } diff --git a/common/actor/src/main/java/org/thingsboard/server/actors/TbActorMailbox.java b/common/actor/src/main/java/org/thingsboard/server/actors/TbActorMailbox.java index 741908a14c..4b822e0030 100644 --- a/common/actor/src/main/java/org/thingsboard/server/actors/TbActorMailbox.java +++ b/common/actor/src/main/java/org/thingsboard/server/actors/TbActorMailbox.java @@ -153,7 +153,7 @@ public final class TbActorMailbox implements TbActorCtx { } if (msg != null) { try { - log.debug("[{}] Going to process message: {}", selfId, msg); + log.trace("[{}] Going to process message: {}", selfId, msg); actor.process(msg); } catch (TbRuleNodeUpdateException updateException) { stopReason = TbActorStopReason.INIT_FAILED; diff --git a/common/actor/src/main/java/org/thingsboard/server/actors/TbCalculatedFieldEntityActorId.java b/common/actor/src/main/java/org/thingsboard/server/actors/TbCalculatedFieldEntityActorId.java new file mode 100644 index 0000000000..3b69fe7eff --- /dev/null +++ b/common/actor/src/main/java/org/thingsboard/server/actors/TbCalculatedFieldEntityActorId.java @@ -0,0 +1,56 @@ +/** + * 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. + */ +package org.thingsboard.server.actors; + +import lombok.Getter; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.Objects; + +public class TbCalculatedFieldEntityActorId implements TbActorId { + + @Getter + private final EntityId entityId; + + public TbCalculatedFieldEntityActorId(EntityId entityId) { + this.entityId = entityId; + } + + @Override + public String toString() { + return entityId.getEntityType() + "|" + entityId.getId(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TbCalculatedFieldEntityActorId that = (TbCalculatedFieldEntityActorId) o; + return entityId.equals(that.entityId); + } + + @Override + public int hashCode() { + // Magic number to ensure that the hash does not match with the hash of other actor id - (TbEntityActorId) + return 42 + Objects.hash(entityId); + } + + @Override + public EntityType getEntityType() { + return entityId.getEntityType(); + } +} diff --git a/common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java b/common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java index 1962e2f147..aed6eb4cf5 100644 --- a/common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java +++ b/common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java @@ -21,6 +21,8 @@ import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.id.EdgeId; @@ -36,7 +38,10 @@ import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest; import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; +import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.RestApiCallResponseMsgProto; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeMsg; @@ -58,6 +63,8 @@ public interface TbClusterService extends TbQueueClusterService { void broadcastToCore(ToCoreNotificationMsg msg); + void broadcastToCalculatedFields(ToCalculatedFieldNotificationMsg build, TbQueueCallback callback); + void pushMsgToVersionControl(TenantId tenantId, ToVersionControlServiceMsg msg, TbQueueCallback callback); void pushNotificationToCore(String targetServiceId, FromDeviceRpcResponse response, TbQueueCallback callback); @@ -74,6 +81,10 @@ public interface TbClusterService extends TbQueueClusterService { void pushNotificationToTransport(String targetServiceId, ToTransportMsg response, TbQueueCallback callback); + void pushMsgToCalculatedFields(TenantId tenantId, EntityId entityId, TransportProtos.ToCalculatedFieldMsg msg, TbQueueCallback callback); + + void pushMsgToCalculatedFields(TopicPartitionInfo tpi, UUID msgId, ToCalculatedFieldMsg msg, TbQueueCallback callback); + void broadcastEntityStateChangeEvent(TenantId tenantId, EntityId entityId, ComponentLifecycleEvent state); void onDeviceProfileChange(DeviceProfile deviceProfile, DeviceProfile oldDeviceProfile, TbQueueCallback callback); @@ -96,6 +107,10 @@ public interface TbClusterService extends TbQueueClusterService { void onDeviceAssignedToTenant(TenantId oldTenantId, Device device); + void onAssetUpdated(Asset asset, Asset old); + + void onAssetDeleted(TenantId tenantId, Asset asset, TbQueueCallback callback); + void onResourceChange(TbResourceInfo resource, TbQueueCallback callback); void onResourceDeleted(TbResourceInfo resource, TbQueueCallback callback); @@ -114,4 +129,8 @@ public interface TbClusterService extends TbQueueClusterService { void sendNotificationMsgToEdge(TenantId tenantId, EdgeId edgeId, EntityId entityId, String body, EdgeEventType type, EdgeEventActionType action, EdgeId sourceEdgeId); + void onCalculatedFieldUpdated(CalculatedField calculatedField, CalculatedField oldCalculatedField, TbQueueCallback callback); + + void onCalculatedFieldDeleted(CalculatedField calculatedField, TbQueueCallback callback); + } diff --git a/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueCallback.java b/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueCallback.java index b8f9fa4e5f..e15d9c8ace 100644 --- a/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueCallback.java +++ b/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueCallback.java @@ -15,8 +15,22 @@ */ package org.thingsboard.server.queue; + public interface TbQueueCallback { + TbQueueCallback EMPTY = new TbQueueCallback() { + + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + + } + + @Override + public void onFailure(Throwable t) { + + } + }; + void onSuccess(TbQueueMsgMetadata metadata); void onFailure(Throwable t); diff --git a/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueRequestTemplate.java b/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueRequestTemplate.java index abde4cce97..609a61d575 100644 --- a/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueRequestTemplate.java +++ b/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueRequestTemplate.java @@ -26,6 +26,8 @@ public interface TbQueueRequestTemplate send(Request request, long timeoutNs); + ListenableFuture send(Request request, Integer partition); + void stop(); void setMessagesStats(MessagesStats messagesStats); diff --git a/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueResponseTemplate.java b/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueResponseTemplate.java index e7e5361381..918d656af0 100644 --- a/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueResponseTemplate.java +++ b/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueResponseTemplate.java @@ -15,9 +15,17 @@ */ package org.thingsboard.server.queue; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; + +import java.util.Set; + public interface TbQueueResponseTemplate { - void init(TbQueueHandler handler); + void subscribe(); + + void subscribe(Set partitions); + + void launch(TbQueueHandler handler); void stop(); } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/alarm/AlarmService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/alarm/AlarmService.java index 7792d05a4c..7be61d15d5 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/alarm/AlarmService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/alarm/AlarmService.java @@ -118,6 +118,8 @@ public interface AlarmService extends EntityDaoService { long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query); + long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query, Collection orderedEntityIds); + PageData findAlarmTypesByTenantId(TenantId tenantId, PageLink pageLink); List findActiveOriginatorAlarms(TenantId tenantId, OriginatorAlarmFilter originatorAlarmFilter, int limit); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java index 04992800eb..09bc8f1f93 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.asset; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetInfo; import org.thingsboard.server.common.data.asset.AssetSearchQuery; @@ -63,6 +64,10 @@ public interface AssetService extends EntityDaoService { PageData findAssetInfosByTenantIdAndAssetProfileId(TenantId tenantId, AssetProfileId assetProfileId, PageLink pageLink); + PageData findProfileEntityIdInfos(PageLink pageLink); + + PageData findAssetIdsByTenantIdAndAssetProfileId(TenantId tenantId, AssetProfileId assetProfileId, PageLink pageLink); + ListenableFuture> findAssetsByTenantIdAndIdsAsync(TenantId tenantId, List assetIds); void deleteAssetsByTenantId(TenantId tenantId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java new file mode 100644 index 0000000000..3d91790790 --- /dev/null +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java @@ -0,0 +1,60 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.cf; + +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.CalculatedFieldLinkId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.entity.EntityDaoService; + +import java.util.List; + +public interface CalculatedFieldService extends EntityDaoService { + + CalculatedField save(CalculatedField calculatedField); + + CalculatedField findById(TenantId tenantId, CalculatedFieldId calculatedFieldId); + + List findCalculatedFieldIdsByEntityId(TenantId tenantId, EntityId entityId); + + List findCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId); + + PageData findAllCalculatedFields(PageLink pageLink); + + PageData findAllCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId, PageLink pageLink); + + void deleteCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId); + + int deleteAllCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId); + + CalculatedFieldLink saveCalculatedFieldLink(TenantId tenantId, CalculatedFieldLink calculatedFieldLink); + + CalculatedFieldLink findCalculatedFieldLinkById(TenantId tenantId, CalculatedFieldLinkId calculatedFieldLinkId); + + List findAllCalculatedFieldLinksById(TenantId tenantId, CalculatedFieldId calculatedFieldId); + + List findAllCalculatedFieldLinksByEntityId(TenantId tenantId, EntityId entityId); + + PageData findAllCalculatedFieldLinks(PageLink pageLink); + + boolean referencedInAnyCalculatedField(TenantId tenantId, EntityId referencedEntityId); + +} diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java index bab59e6d66..4ef653855d 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.device.DeviceSearchQuery; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; @@ -73,8 +74,12 @@ public interface DeviceService extends EntityDaoService { PageData findDeviceIdInfos(PageLink pageLink); + PageData findProfileEntityIdInfos(PageLink pageLink); + PageData findDevicesByTenantIdAndType(TenantId tenantId, String type, PageLink pageLink); + PageData findDeviceIdsByTenantIdAndDeviceProfileId(TenantId tenantId, DeviceProfileId deviceProfileId, PageLink pageLink); + PageData findDevicesByTenantIdAndTypeAndEmptyOtaPackage(TenantId tenantId, DeviceProfileId deviceProfileId, OtaPackageType type, PageLink pageLink); long countDevicesByTenantIdAndDeviceProfileIdAndEmptyOtaPackage(TenantId tenantId, DeviceProfileId deviceProfileId, OtaPackageType otaPackageType); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java index d2fe51a133..65db0d5a76 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.entity; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.NameLabelAndCustomerDetails; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -34,6 +35,8 @@ public interface EntityService { Optional fetchEntityCustomerId(TenantId tenantId, EntityId entityId); + Optional> fetchEntity(TenantId tenantId, EntityId entityId); + Optional fetchNameLabelAndCustomerDetails(TenantId tenantId, EntityId entityId); long countEntitiesByQuery(TenantId tenantId, CustomerId customerId, EntityCountQuery query); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/rule/RuleChainService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/rule/RuleChainService.java index b96a984f71..a2356ee149 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/rule/RuleChainService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/rule/RuleChainService.java @@ -46,6 +46,8 @@ public interface RuleChainService extends EntityDaoService { RuleChain saveRuleChain(RuleChain ruleChain, boolean publishSaveEvent); + RuleChain saveRuleChain(RuleChain ruleChain, boolean publishSaveEvent, boolean doValidate); + boolean setRootRuleChain(TenantId tenantId, RuleChainId ruleChainId); RuleChainUpdateResult saveRuleChainMetaData(TenantId tenantId, RuleChainMetaData ruleChainMetaData, Function ruleNodeUpdater); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java index 862c50e45e..e239e22ee9 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult; +import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult; @@ -44,13 +45,13 @@ public interface TimeseriesService { ListenableFuture> findAllLatest(TenantId tenantId, EntityId entityId); - ListenableFuture save(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry); + ListenableFuture save(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry); - ListenableFuture save(TenantId tenantId, EntityId entityId, List tsKvEntry, long ttl); + ListenableFuture save(TenantId tenantId, EntityId entityId, List tsKvEntry, long ttl); - ListenableFuture saveWithoutLatest(TenantId tenantId, EntityId entityId, List tsKvEntry, long ttl); + ListenableFuture saveWithoutLatest(TenantId tenantId, EntityId entityId, List tsKvEntry, long ttl); - ListenableFuture> saveLatest(TenantId tenantId, EntityId entityId, List tsKvEntry); + ListenableFuture saveLatest(TenantId tenantId, EntityId entityId, List tsKvEntries); ListenableFuture> remove(TenantId tenantId, EntityId entityId, List queries); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java index e80a5d86e7..b53d6daec2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java @@ -145,4 +145,7 @@ public class DataConstants { public static final String EDGE_QUEUE_NAME = "Edge"; public static final String EDGE_EVENT_QUEUE_NAME = "EdgeEvent"; + public static final String CF_QUEUE_NAME = "CalculatedFields"; + public static final String CF_STATES_QUEUE_NAME = "CalculatedFieldStates"; + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java index f5de438a9f..1c56aa25dd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java @@ -61,7 +61,9 @@ public enum EntityType { OAUTH2_CLIENT(35), DOMAIN(36), MOBILE_APP(37), - MOBILE_APP_BUNDLE(38); + MOBILE_APP_BUNDLE(38), + CALCULATED_FIELD(39), + CALCULATED_FIELD_LINK(40); @Getter private final int protoNumber; // Corresponds to EntityTypeProto @@ -84,4 +86,15 @@ public enum EntityType { this.tableName = tableName; } + public boolean isOneOf(EntityType... types) { + if (types == null) { + return false; + } + for (EntityType type : types) { + if (this == type) { + return true; + } + } + return false; + } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ObjectType.java b/common/data/src/main/java/org/thingsboard/server/common/data/ObjectType.java new file mode 100644 index 0000000000..51c631fe4f --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ObjectType.java @@ -0,0 +1,89 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data; + +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +public enum ObjectType { + TENANT, + TENANT_PROFILE, + CUSTOMER, + QUEUE, + RPC, + RULE_CHAIN, + OTA_PACKAGE, + RESOURCE, + EVENT, + RULE_NODE, + USER, + EDGE, + WIDGETS_BUNDLE, + WIDGET_TYPE, + DASHBOARD, + DEVICE_PROFILE, + DEVICE, + DEVICE_CREDENTIALS, + ASSET_PROFILE, + ASSET, + ENTITY_VIEW, + ALARM, + ENTITY_ALARM, + OAUTH2_CLIENT, + OAUTH2_DOMAIN, + OAUTH2_MOBILE, + USER_SETTINGS, + NOTIFICATION_TARGET, + NOTIFICATION_TEMPLATE, + NOTIFICATION_RULE, + ALARM_COMMENT, + API_USAGE_STATE, + QUEUE_STATS, + + AUDIT_LOG, + RELATION, + ATTRIBUTE_KV, + LATEST_TS_KV; + + public static final Set edqsTenantTypes = EnumSet.of( + TENANT, CUSTOMER, DEVICE_PROFILE, DEVICE, ASSET_PROFILE, ASSET, EDGE, ENTITY_VIEW, USER, DASHBOARD, + RULE_CHAIN, WIDGET_TYPE, WIDGETS_BUNDLE, API_USAGE_STATE, QUEUE_STATS + ); + public static final Set edqsTypes = EnumSet.copyOf(edqsTenantTypes); + public static final Set edqsSystemTypes = EnumSet.of(TENANT, USER, DASHBOARD, + API_USAGE_STATE, ATTRIBUTE_KV, LATEST_TS_KV); + public static final Set unversionedTypes = EnumSet.of( + QUEUE_STATS // created once, never updated + ); + + static { + edqsTypes.addAll(List.of(RELATION, ATTRIBUTE_KV, LATEST_TS_KV)); + } + + public EntityType toEntityType() { + return EntityType.valueOf(name()); + } + + public static ObjectType fromEntityType(EntityType entityType) { + try { + return ObjectType.valueOf(entityType.name()); + } catch (Exception e) { + return null; + } + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ProfileEntityIdInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/ProfileEntityIdInfo.java new file mode 100644 index 0000000000..22934de813 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ProfileEntityIdInfo.java @@ -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. + */ +package org.thingsboard.server.common.data; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; + +import java.io.Serializable; +import java.util.UUID; + +@Data +@Slf4j +public class ProfileEntityIdInfo implements Serializable, HasTenantId { + + private static final long serialVersionUID = 8532058281983868003L; + + private final TenantId tenantId; + private final EntityId profileId; + private final EntityId entityId; + + private ProfileEntityIdInfo(UUID tenantId, EntityId profileId, EntityId entityId) { + this.tenantId = TenantId.fromUUID(tenantId); + this.profileId = profileId; + this.entityId = entityId; + } + + public static ProfileEntityIdInfo create(UUID tenantId, DeviceProfileId profileId, DeviceId entityId) { + return new ProfileEntityIdInfo(tenantId, profileId, entityId); + } + + public static ProfileEntityIdInfo create(UUID tenantId, AssetProfileId profileId, AssetId entityId) { + return new ProfileEntityIdInfo(tenantId, profileId, entityId); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java index 0856f65264..b1ef4d7f22 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java @@ -34,4 +34,7 @@ public class SystemParams { boolean mobileQrEnabled; int maxDebugModeDurationMinutes; String ruleChainDebugPerTenantLimitsConfiguration; + String calculatedFieldDebugPerTenantLimitsConfiguration; + long maxArgumentsPerCF; + long maxDataPointsPerRollingArg; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TenantProfile.java b/common/data/src/main/java/org/thingsboard/server/common/data/TenantProfile.java index b7c7584931..5c7f0bb53d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TenantProfile.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TenantProfile.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; @@ -58,6 +59,7 @@ public class TenantProfile extends BaseData implements HasName @Schema(description = "If enabled, will push all messages related to this tenant and processed by the rule engine into separate queue. " + "Useful for complex microservices deployments, to isolate processing of the data for specific tenants", example = "false") private boolean isolatedTbRuleEngine; + @Valid @Schema(description = "Complex JSON object that contains profile settings: queue configs, max devices, max assets, rate limits, etc.") private transient TenantProfileData profileData; @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java new file mode 100644 index 0000000000..b86f30ca78 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java @@ -0,0 +1,143 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.cf; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonSetter; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.HasDebugSettings; +import org.thingsboard.server.common.data.HasName; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.HasVersion; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.debug.DebugSettings; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.validation.Length; +import org.thingsboard.server.common.data.validation.NoXss; + +import java.io.Serial; + +@Schema +@Data +@EqualsAndHashCode(callSuper = true) +public class CalculatedField extends BaseData implements HasName, HasTenantId, HasVersion, HasDebugSettings { + + @Serial + private static final long serialVersionUID = 4491966747773381420L; + + private TenantId tenantId; + private EntityId entityId; + + @NoXss + @Length(fieldName = "type") + private CalculatedFieldType type; + @NoXss + @Length(fieldName = "name") + @Schema(description = "User defined name of the calculated field.") + private String name; + @Deprecated + @Schema(description = "Enable/disable debug. ", example = "false", deprecated = true) + private boolean debugMode; + @Schema(description = "Debug settings object.") + private DebugSettings debugSettings; + @Schema(description = "Version of calculated field configuration.", example = "0") + private int configurationVersion; + @Schema(implementation = SimpleCalculatedFieldConfiguration.class) + private transient CalculatedFieldConfiguration configuration; + @Getter + @Setter + private Long version; + + public CalculatedField() { + super(); + } + + public CalculatedField(CalculatedFieldId id) { + super(id); + } + + public CalculatedField(TenantId tenantId, EntityId entityId, CalculatedFieldType type, String name, int configurationVersion, CalculatedFieldConfiguration configuration, Long version) { + this.tenantId = tenantId; + this.entityId = entityId; + this.type = type; + this.name = name; + this.configurationVersion = configurationVersion; + this.configuration = configuration; + this.version = version; + } + + public CalculatedField(CalculatedField calculatedField) { + super(calculatedField); + this.tenantId = calculatedField.tenantId; + this.entityId = calculatedField.entityId; + this.type = calculatedField.type; + this.name = calculatedField.name; + this.debugMode = calculatedField.debugMode; + this.debugSettings = calculatedField.debugSettings; + this.configurationVersion = calculatedField.configurationVersion; + this.configuration = calculatedField.configuration; + this.version = calculatedField.version; + } + + @Schema(description = "JSON object with the Calculated Field Id. Referencing non-existing Calculated Field Id will cause error.") + @Override + public CalculatedFieldId getId() { + return super.getId(); + } + + @Schema(description = "Timestamp of the calculated field creation, in milliseconds", example = "1609459200000", accessMode = Schema.AccessMode.READ_ONLY) + @Override + public long getCreatedTime() { + return super.getCreatedTime(); + } + + @Override + public String toString() { + return new StringBuilder() + .append("CalculatedField[") + .append("tenantId=").append(tenantId) + .append(", entityId=").append(entityId) + .append(", type='").append(type) + .append(", name='").append(name) + .append(", configurationVersion=").append(configurationVersion) + .append(", configuration=").append(configuration) + .append(", version=").append(version) + .append(", createdTime=").append(createdTime) + .append(", id=").append(id).append(']') + .toString(); + } + + // Getter is ignored for serialization + @JsonIgnore + public boolean isDebugMode() { + return debugMode; + } + + // Setter is annotated for deserialization + @JsonSetter + public void setDebugMode(boolean debugMode) { + this.debugMode = debugMode; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldLink.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldLink.java new file mode 100644 index 0000000000..3f048815da --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldLink.java @@ -0,0 +1,66 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.cf; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.CalculatedFieldLinkId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; + +@Schema +@Data +@EqualsAndHashCode(callSuper = true) +public class CalculatedFieldLink extends BaseData { + + private static final long serialVersionUID = 6492846246722091530L; + + private TenantId tenantId; + private EntityId entityId; + + @Schema(description = "JSON object with the Calculated Field Id. ", accessMode = Schema.AccessMode.READ_ONLY) + private CalculatedFieldId calculatedFieldId; + + public CalculatedFieldLink() { + super(); + } + + public CalculatedFieldLink(CalculatedFieldLinkId id) { + super(id); + } + + public CalculatedFieldLink(TenantId tenantId, EntityId entityId, CalculatedFieldId calculatedFieldId) { + this.tenantId = tenantId; + this.entityId = entityId; + this.calculatedFieldId = calculatedFieldId; + } + + @Override + public String toString() { + return new StringBuilder() + .append("CalculatedFieldLink[") + .append("tenantId=").append(tenantId) + .append(", entityId=").append(entityId) + .append(", calculatedFieldId=").append(calculatedFieldId) + .append(", createdTime=").append(createdTime) + .append(", id=").append(id).append(']') + .toString(); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java new file mode 100644 index 0000000000..acef67a041 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java @@ -0,0 +1,22 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.cf; + +public enum CalculatedFieldType { + + SIMPLE, SCRIPT + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java new file mode 100644 index 0000000000..e7daa70b1b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java @@ -0,0 +1,35 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.cf.configuration; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import org.springframework.lang.Nullable; +import org.thingsboard.server.common.data.id.EntityId; + +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Argument { + + @Nullable + private EntityId refEntityId; + private ReferencedEntityKey refEntityKey; + private String defaultValue; + + private Integer limit; + private Long timeWindow; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentType.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentType.java new file mode 100644 index 0000000000..7f057f4038 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentType.java @@ -0,0 +1,22 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.cf.configuration; + +public enum ArgumentType { + + TS_LATEST, ATTRIBUTE, TS_ROLLING + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java new file mode 100644 index 0000000000..8227ff4603 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java @@ -0,0 +1,61 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.cf.configuration; + +import lombok.Data; +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +@Data +public abstract class BaseCalculatedFieldConfiguration implements CalculatedFieldConfiguration { + + protected Map arguments; + protected String expression; + protected Output output; + + @Override + public List getReferencedEntities() { + return arguments.values().stream() + .map(Argument::getRefEntityId) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + @Override + public List buildCalculatedFieldLinks(TenantId tenantId, EntityId cfEntityId, CalculatedFieldId calculatedFieldId) { + return getReferencedEntities().stream() + .filter(referencedEntity -> !referencedEntity.equals(cfEntityId)) + .map(referencedEntityId -> buildCalculatedFieldLink(tenantId, referencedEntityId, calculatedFieldId)) + .collect(Collectors.toList()); + } + + @Override + public CalculatedFieldLink buildCalculatedFieldLink(TenantId tenantId, EntityId referencedEntityId, CalculatedFieldId calculatedFieldId) { + CalculatedFieldLink link = new CalculatedFieldLink(); + link.setTenantId(tenantId); + link.setEntityId(referencedEntityId); + link.setCalculatedFieldId(calculatedFieldId); + return link; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java new file mode 100644 index 0000000000..c53f1fe5f1 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java @@ -0,0 +1,59 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.cf.configuration; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.List; +import java.util.Map; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = SimpleCalculatedFieldConfiguration.class, name = "SIMPLE"), + @JsonSubTypes.Type(value = ScriptCalculatedFieldConfiguration.class, name = "SCRIPT") +}) +public interface CalculatedFieldConfiguration { + + @JsonIgnore + CalculatedFieldType getType(); + + Map getArguments(); + + String getExpression(); + + void setExpression(String expression); + + Output getOutput(); + + @JsonIgnore + List getReferencedEntities(); + + List buildCalculatedFieldLinks(TenantId tenantId, EntityId cfEntityId, CalculatedFieldId calculatedFieldId); + + CalculatedFieldLink buildCalculatedFieldLink(TenantId tenantId, EntityId referencedEntityId, CalculatedFieldId calculatedFieldId); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java new file mode 100644 index 0000000000..f2b4948837 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java @@ -0,0 +1,31 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.cf.configuration; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import org.thingsboard.server.common.data.AttributeScope; + +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Output { + + private String name; + private OutputType type; + private AttributeScope scope; + private Integer decimalsByDefault; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputType.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputType.java new file mode 100644 index 0000000000..04a816b74f --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputType.java @@ -0,0 +1,22 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.cf.configuration; + +public enum OutputType { + + TIME_SERIES, ATTRIBUTES + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ReferencedEntityKey.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ReferencedEntityKey.java new file mode 100644 index 0000000000..9e3a75c891 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ReferencedEntityKey.java @@ -0,0 +1,32 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.cf.configuration; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Data; +import org.thingsboard.server.common.data.AttributeScope; + +@Data +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ReferencedEntityKey { + + private String key; + private ArgumentType type; + private AttributeScope scope; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java new file mode 100644 index 0000000000..0971217fdf --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java @@ -0,0 +1,28 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.cf.configuration; + +import lombok.Data; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; + +@Data +public class ScriptCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements CalculatedFieldConfiguration { + + @Override + public CalculatedFieldType getType() { + return CalculatedFieldType.SCRIPT; + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java new file mode 100644 index 0000000000..79a0518ba0 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java @@ -0,0 +1,28 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.cf.configuration; + +import lombok.Data; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; + +@Data +public class SimpleCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements CalculatedFieldConfiguration { + + @Override + public CalculatedFieldType getType() { + return CalculatedFieldType.SIMPLE; + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/AttributeKv.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/AttributeKv.java new file mode 100644 index 0000000000..c162365257 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/AttributeKv.java @@ -0,0 +1,75 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.KvEntry; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class AttributeKv implements EdqsObject { + + private EntityId entityId; + private AttributeScope scope; + private String key; + private Long version; + + private DataPoint dataPoint; // optional (on deletion) + + private Long lastUpdateTs; // only for serialization + private KvEntry value; // only for serialization + + public AttributeKv(EntityId entityId, AttributeScope scope, AttributeKvEntry attributeKvEntry, long version) { + this.entityId = entityId; + this.scope = scope; + this.key = attributeKvEntry.getKey(); + this.version = version; + this.lastUpdateTs = attributeKvEntry.getLastUpdateTs(); + this.value = attributeKvEntry; + } + + public AttributeKv(EntityId entityId, AttributeScope scope, String key, long version) { + this.entityId = entityId; + this.scope = scope; + this.key = key; + this.version = version; + } + + @Override + public String key() { + return "a_" + entityId + "_" + scope + "_" + key; + } + + @Override + public Long version() { + return version; + } + + @Override + public ObjectType type() { + return ObjectType.ATTRIBUTE_KV; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/DataPoint.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/DataPoint.java new file mode 100644 index 0000000000..a6f30c8004 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/DataPoint.java @@ -0,0 +1,40 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs; + +import org.thingsboard.server.common.data.kv.DataType; + +public interface DataPoint { + + String NOT_SUPPORTED = "Not supported!"; + + long getTs(); + + DataType getType(); + + String getStr(); + + long getLong(); + + double getDouble(); + + boolean getBool(); + + String getJson(); + + String valueToString(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsEvent.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsEvent.java new file mode 100644 index 0000000000..50c8d268de --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsEvent.java @@ -0,0 +1,34 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.id.TenantId; + +@Data +@AllArgsConstructor +@Builder +public class EdqsEvent { + + private final TenantId tenantId; + private final ObjectType objectType; + private final EdqsEventType eventType; + private final EdqsObject object; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsEventType.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsEventType.java new file mode 100644 index 0000000000..df31048365 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsEventType.java @@ -0,0 +1,21 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs; + +public enum EdqsEventType { + UPDATED, + DELETED +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsObject.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsObject.java new file mode 100644 index 0000000000..a74c90208a --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsObject.java @@ -0,0 +1,32 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import org.thingsboard.server.common.data.ObjectType; + +public interface EdqsObject { + + @JsonIgnore + String key(); + + @JsonIgnore + Long version(); + + @JsonIgnore + ObjectType type(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsSyncRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsSyncRequest.java new file mode 100644 index 0000000000..12f7068c71 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/EdqsSyncRequest.java @@ -0,0 +1,24 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties +public class EdqsSyncRequest { +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/Entity.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/Entity.java new file mode 100644 index 0000000000..c22ef147e3 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/Entity.java @@ -0,0 +1,68 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.edqs.fields.EntityFields; +import org.thingsboard.server.common.data.edqs.fields.EntityIdFields; + +import java.util.UUID; + +@Data +@NoArgsConstructor +public class Entity implements EdqsObject { + + private EntityType type; + + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) + private EntityFields fields; + + public Entity(EntityType type) { + this.type = type; + } + + public Entity(EntityType type, EntityFields fields) { + this.type = type; + this.fields = fields; + } + + public Entity(EntityType entityType, UUID id, long version) { + this.type = entityType; + this.fields = new EntityIdFields(id, version); + } + + @Override + public String key() { + return "e_" + fields.getId().toString(); + } + + @Override + public Long version() { + return fields.getVersion(); + } + + @Override + public ObjectType type() { + return ObjectType.fromEntityType(type); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/LatestTsKv.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/LatestTsKv.java new file mode 100644 index 0000000000..8bd69c41a4 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/LatestTsKv.java @@ -0,0 +1,70 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class LatestTsKv implements EdqsObject { + + private EntityId entityId; + private String key; + private Long version; + + private DataPoint dataPoint; // optional (on deletion) + + private Long ts; // only for serialization + private KvEntry value; // only for serialization + + public LatestTsKv(EntityId entityId, TsKvEntry tsKvEntry, Long version) { + this.entityId = entityId; + this.key = tsKvEntry.getKey(); + this.ts = tsKvEntry.getTs(); + this.version = version != null ? version : 0L; + this.value = tsKvEntry; + } + + public LatestTsKv(EntityId entityId, String key, Long version) { + this.entityId = entityId; + this.key = key; + this.version = version != null ? version : 0L; + } + + public String key() { + return "l_" + entityId + "_" + key; + } + + @Override + public Long version() { + return version; + } + + @Override + public ObjectType type() { + return ObjectType.LATEST_TS_KV; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/ToCoreEdqsMsg.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/ToCoreEdqsMsg.java new file mode 100644 index 0000000000..78bebba20a --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/ToCoreEdqsMsg.java @@ -0,0 +1,32 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class ToCoreEdqsMsg { + + private EdqsSyncRequest syncRequest; + private Boolean apiEnabled; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/ToCoreEdqsRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/ToCoreEdqsRequest.java new file mode 100644 index 0000000000..c4f262fbf0 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/ToCoreEdqsRequest.java @@ -0,0 +1,38 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ToCoreEdqsRequest { + + private EdqsSyncRequest syncRequest; + private Boolean apiEnabled; + + @JsonIgnore + public ToCoreEdqsMsg toInternalMsg() { + return new ToCoreEdqsMsg(syncRequest, apiEnabled); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AbstractEntityFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AbstractEntityFields.java new file mode 100644 index 0000000000..4aad5eb4dd --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AbstractEntityFields.java @@ -0,0 +1,65 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import lombok.Data; +import lombok.experimental.SuperBuilder; +import org.thingsboard.server.common.data.id.CustomerId; + +import java.util.UUID; + +@Data +@SuperBuilder +public class AbstractEntityFields implements EntityFields { + + private UUID id; + private long createdTime; + private UUID tenantId; + private UUID customerId; + private String name; + private Long version; + + public AbstractEntityFields(UUID id, long createdTime, UUID tenantId, UUID customerId, String name, Long version) { + this.id = id; + this.createdTime = createdTime; + this.tenantId = tenantId; + this.customerId = (customerId != null && customerId != CustomerId.NULL_UUID) ? customerId : null; + this.name = name; + this.version = version; + } + + public AbstractEntityFields() { + } + + public AbstractEntityFields(UUID id, long createdTime, UUID tenantId, String name, Long version) { + this(id, createdTime, tenantId, null, name, version); + } + + public AbstractEntityFields(UUID id, long createdTime, UUID tenantId, UUID customerId, Long version) { + this(id, createdTime, tenantId, customerId, null, version); + + } + + public AbstractEntityFields(UUID id, long createdTime, String name, Long version) { + this(id, createdTime, null, name, version); + } + + + public AbstractEntityFields(UUID id, long createdTime, UUID tenantId) { + this(id, createdTime, tenantId, null, null, null); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/ApiUsageStateFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/ApiUsageStateFields.java new file mode 100644 index 0000000000..d10f375bc1 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/ApiUsageStateFields.java @@ -0,0 +1,59 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.thingsboard.server.common.data.ApiUsageStateValue; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; + +import java.util.UUID; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@SuperBuilder +public class ApiUsageStateFields extends AbstractEntityFields { + + private EntityId entityId; + private ApiUsageStateValue transportState; + private ApiUsageStateValue dbStorageState; + private ApiUsageStateValue reExecState; + private ApiUsageStateValue jsExecState; + private ApiUsageStateValue tbelExecState; + private ApiUsageStateValue emailExecState; + private ApiUsageStateValue smsExecState; + private ApiUsageStateValue alarmExecState; + + public ApiUsageStateFields(UUID id, long createdTime, UUID tenantId, UUID entityId, String entityType, ApiUsageStateValue transportState, ApiUsageStateValue dbStorageState, + ApiUsageStateValue reExecState, ApiUsageStateValue jsExecState, ApiUsageStateValue tbelExecState, + ApiUsageStateValue emailExecState, ApiUsageStateValue smsExecState, ApiUsageStateValue alarmExecState, + Long version) { + super(id, createdTime, tenantId, null, null, version); + this.entityId = (entityType != null && entityId != null) ? EntityIdFactory.getByTypeAndUuid(entityType, entityId) : null; + this.transportState = transportState; + this.dbStorageState = dbStorageState; + this.reExecState = reExecState; + this.jsExecState = jsExecState; + this.tbelExecState = tbelExecState; + this.emailExecState = emailExecState; + this.smsExecState = smsExecState; + this.alarmExecState = alarmExecState; + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AssetFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AssetFields.java new file mode 100644 index 0000000000..b4020e5788 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AssetFields.java @@ -0,0 +1,58 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.UUID; + +import static org.thingsboard.server.common.data.edqs.fields.FieldsUtil.getText; + +@Data +@NoArgsConstructor +@SuperBuilder +public class AssetFields extends AbstractEntityFields implements ProfileAwareFields { + + private String type; + private UUID assetProfileId; + private String label; + private String additionalInfo; + + @JsonIgnore + @Override + public String getProfileName() { + return type; + } + + @JsonIgnore + @Override + public UUID getProfileId() { + return assetProfileId; + } + + public AssetFields(UUID id, long createdTime, UUID tenantId, UUID customerId, String name, + Long version, String type, String label, UUID assetProfileId, JsonNode additionalInfo) { + super(id, createdTime, tenantId, customerId, name, version); + this.type = type; + this.assetProfileId = assetProfileId; + this.label = label; + this.additionalInfo = getText(additionalInfo); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AssetProfileFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AssetProfileFields.java new file mode 100644 index 0000000000..56d8691cbe --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/AssetProfileFields.java @@ -0,0 +1,35 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@SuperBuilder +public class AssetProfileFields extends AbstractEntityFields { + + private boolean isDefault; + + public AssetProfileFields(UUID id, long createdTime, UUID tenantId, String name, Long version, boolean isDefault) { + super(id, createdTime, tenantId, null, name, version); + this.isDefault = isDefault; + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/CustomerFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/CustomerFields.java new file mode 100644 index 0000000000..4132e9e094 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/CustomerFields.java @@ -0,0 +1,55 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.UUID; + +import static org.thingsboard.server.common.data.edqs.fields.FieldsUtil.getText; + +@Data +@NoArgsConstructor +@SuperBuilder +public class CustomerFields extends AbstractEntityFields { + + private String additionalInfo; + private String country; + private String state; + private String city; + private String address; + private String address2; + private String zip; + private String phone; + private String email; + + public CustomerFields(UUID id, long createdTime, UUID tenantId, String name, Long version, JsonNode additionalInfo, + String country, String state, String city, String address, String address2, String zip, String phone, String email) { + super(id, createdTime, tenantId, name, version); + this.additionalInfo = getText(additionalInfo); + this.country = country; + this.state = state; + this.city = city; + this.address = address; + this.address2 = address2; + this.zip = zip; + this.phone = phone; + this.email = email; + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/DashboardFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/DashboardFields.java new file mode 100644 index 0000000000..0061b7e10b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/DashboardFields.java @@ -0,0 +1,61 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + + +@Data +@NoArgsConstructor +@SuperBuilder +public class DashboardFields extends AbstractEntityFields { + + private static ObjectMapper objectMapper = new ObjectMapper(); + private List assignedCustomerIds; + + public DashboardFields(UUID id, long createdTime, UUID tenantId, String assignedCustomers, String name, Long version) { + super(id, createdTime, tenantId, name, version); + this.assignedCustomerIds = getCustomerIds(assignedCustomers); + } + + private static List getCustomerIds(String assignedCustomers) { + List ids = new ArrayList<>(); + if (assignedCustomers == null || assignedCustomers.isEmpty()) { + return ids; + } + try { + JsonNode rootNode = objectMapper.readTree(assignedCustomers); + for (JsonNode node : rootNode) { + String idStr = node.path("customerId").path("id").asText(); + if (!idStr.isEmpty()) { + ids.add(UUID.fromString(idStr)); + } + } + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + return ids; + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/DeviceFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/DeviceFields.java new file mode 100644 index 0000000000..ea1ef383de --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/DeviceFields.java @@ -0,0 +1,58 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.UUID; + +import static org.thingsboard.server.common.data.edqs.fields.FieldsUtil.getText; + +@Data +@NoArgsConstructor +@SuperBuilder +public class DeviceFields extends AbstractEntityFields implements ProfileAwareFields { + + private String label; + private String type; + private UUID deviceProfileId; + private String additionalInfo; + + @JsonIgnore + @Override + public String getProfileName() { + return type; + } + + @JsonIgnore + @Override + public UUID getProfileId() { + return deviceProfileId; + } + + public DeviceFields(UUID id, long createdTime, UUID tenantId, UUID customerId, String name, Long version, String type, + String label, UUID deviceProfileId, JsonNode additionalInfo) { + super(id, createdTime, tenantId, customerId, name, version); + this.label = label; + this.type = type; + this.deviceProfileId = deviceProfileId; + this.additionalInfo = getText(additionalInfo); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/DeviceProfileFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/DeviceProfileFields.java new file mode 100644 index 0000000000..1e8d56ab14 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/DeviceProfileFields.java @@ -0,0 +1,38 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.thingsboard.server.common.data.DeviceProfileType; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@SuperBuilder +public class DeviceProfileFields extends AbstractEntityFields { + + private String type; + private boolean isDefault; + + public DeviceProfileFields(UUID id, long createdTime, UUID tenantId, String name, Long version, DeviceProfileType type, boolean isDefault) { + super(id, createdTime, tenantId, null, name, version); + this.type = type.name(); + this.isDefault = isDefault; + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/EdgeFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/EdgeFields.java new file mode 100644 index 0000000000..483a7f2680 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/EdgeFields.java @@ -0,0 +1,43 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.UUID; + +import static org.thingsboard.server.common.data.edqs.fields.FieldsUtil.getText; + +@Data +@NoArgsConstructor +@SuperBuilder +public class EdgeFields extends AbstractEntityFields { + + private String type; + private String label; + private String additionalInfo; + + public EdgeFields(UUID id, long createdTime, UUID tenantId, UUID customerId, String name, Long version, + String type, String label, JsonNode additionalInfo) { + super(id, createdTime, tenantId, customerId, name, version); + this.type = type; + this.label = label; + this.additionalInfo = getText(additionalInfo); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/EntityFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/EntityFields.java new file mode 100644 index 0000000000..1b0975542c --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/EntityFields.java @@ -0,0 +1,180 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import com.fasterxml.jackson.annotation.JsonInclude; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public interface EntityFields { + + Logger log = LoggerFactory.getLogger(EntityFields.class); + + default UUID getId() { + return null; + } + + default UUID getTenantId() { + return null; + } + + default UUID getCustomerId() { + return null; + } + + default List getAssignedCustomerIds() { + return Collections.emptyList(); + } + + default long getCreatedTime() { + return 0; + } + + default String getName() { + return ""; + } + + default String getType() { + return ""; + } + + default String getLabel() { + return ""; + } + + default String getAdditionalInfo() { + return ""; + } + + default String getEmail() { + return ""; + } + + default String getCountry() { + return ""; + } + + default String getState() { + return ""; + } + + default String getCity() { + return ""; + } + + default String getAddress() { + return ""; + } + + default String getAddress2() { + return ""; + } + + default String getZip() { + return ""; + } + + default String getPhone() { + return ""; + } + + default String getRegion() { + return ""; + } + + default String getFirstName() { + return ""; + } + + default String getLastName() { + return ""; + } + + default boolean isEdgeTemplate() { + return false; + } + + default String getConfiguration() { + return ""; + } + + default String getSchedule() { + return ""; + } + + default EntityId getOriginatorId() { + return null; + } + + default String getQueueName() { + return ""; + } + + default String getServiceId() { + return ""; + } + + default boolean isDefault() { + return false; + } + + default UUID getOwnerId() { + return null; + } + + default Long getVersion() { + return null; + } + + default String getAsString(String key) { + return switch (key) { + case "createdTime" -> Long.toString(getCreatedTime()); + case "title" -> getName(); + case "type" -> getType(); + case "label" -> getLabel(); + case "additionalInfo" -> getAdditionalInfo(); + case "email" -> getEmail(); + case "country" -> getCountry(); + case "state" -> getState(); + case "city" -> getCity(); + case "address" -> getAddress(); + case "address2" -> getAddress2(); + case "zip" -> getZip(); + case "phone" -> getPhone(); + case "region" -> getRegion(); + case "firstName" -> getFirstName(); + case "lastName" -> getLastName(); + case "edgeTemplate" -> Boolean.toString(isEdgeTemplate()); + case "configuration" -> getConfiguration(); + case "schedule" -> getSchedule(); + case "originatorId" -> getOriginatorId().getId().toString(); + case "originatorType" -> getOriginatorId().getEntityType().toString(); + case "queueName" -> getQueueName(); + case "serviceId" -> getServiceId(); + default -> { + log.warn("Unknown field '{}'", key); + yield null; + } + }; + } + +} diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeConfigurationTest.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/EntityIdFields.java similarity index 60% rename from rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeConfigurationTest.java rename to common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/EntityIdFields.java index 5c4d341fda..835577c4f1 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeConfigurationTest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/EntityIdFields.java @@ -13,17 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.rule.engine.telemetry; +package org.thingsboard.server.common.data.edqs.fields; -import org.junit.jupiter.api.Test; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; -import static org.assertj.core.api.Assertions.assertThat; +import java.util.UUID; -class TbMsgAttributesNodeConfigurationTest { +@Data +@NoArgsConstructor +@SuperBuilder +public class EntityIdFields implements EntityFields { - @Test - void testDefaultConfig_givenUpdateAttributesOnlyOnValueChange_thenTrue_sinceVersion1() { - assertThat(new TbMsgAttributesNodeConfiguration().defaultConfiguration().isUpdateAttributesOnlyOnValueChange()).isTrue(); - } + private UUID id; + private Long version; + public EntityIdFields(UUID id, Long version) { + this.id = id; + this.version = version; + } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/EntityViewFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/EntityViewFields.java new file mode 100644 index 0000000000..ba3c105a87 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/EntityViewFields.java @@ -0,0 +1,40 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.UUID; + +import static org.thingsboard.server.common.data.edqs.fields.FieldsUtil.getText; + +@Data +@NoArgsConstructor +@SuperBuilder +public class EntityViewFields extends AbstractEntityFields { + + private String type; + private String additionalInfo; + + public EntityViewFields(UUID id, long createdTime, UUID tenantId, UUID customerId, String name, String type, JsonNode additionalInfo, Long version) { + super(id, createdTime, tenantId, customerId, name, version); + this.type = type; + this.additionalInfo = getText(additionalInfo); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/FieldsUtil.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/FieldsUtil.java new file mode 100644 index 0000000000..9ba6c20188 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/FieldsUtil.java @@ -0,0 +1,299 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import com.fasterxml.jackson.databind.JsonNode; +import org.thingsboard.server.common.data.ApiUsageState; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileType; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.queue.QueueStats; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.data.widget.WidgetType; +import org.thingsboard.server.common.data.widget.WidgetsBundle; + +import java.util.UUID; + +public class FieldsUtil { + + public static EntityFields toFields(Object entity) { + if (entity instanceof Customer customer) { + return toFields(customer); + } else if (entity instanceof Tenant tenant) { + return toFields(tenant); + } else if (entity instanceof TenantProfile tenantProfile) { + return toFields(tenantProfile); + } else if (entity instanceof Device device) { + return toFields(device); + } else if (entity instanceof Asset asset) { + return toFields(asset); + } else if (entity instanceof Edge edge) { + return toFields(edge); + } else if (entity instanceof EntityView entityView) { + return toFields(entityView); + } else if (entity instanceof User user) { + return toFields(user); + } else if (entity instanceof Dashboard dashboard) { + return toFields(dashboard); + } else if (entity instanceof RuleChain ruleChain) { + return toFields(ruleChain); + } else if (entity instanceof RuleNode ruleNode) { + return toFields(ruleNode); + } else if (entity instanceof WidgetType widgetType) { + return toFields(widgetType); + } else if (entity instanceof WidgetsBundle widgetsBundle) { + return toFields(widgetsBundle); + } else if (entity instanceof DeviceProfile deviceProfile) { + return toFields(deviceProfile); + } else if (entity instanceof AssetProfile assetProfile) { + return toFields(assetProfile); + } else if (entity instanceof QueueStats queueStats) { + return toFields(queueStats); + } else if (entity instanceof ApiUsageState apiUsageState) { + return toFields(apiUsageState); + } else { + throw new IllegalArgumentException("Unsupported entity type: " + entity.getClass().getName()); + } + } + + private static CustomerFields toFields(Customer entity) { + return CustomerFields.builder() + .id(entity.getUuidId()) + .createdTime(entity.getCreatedTime()) + .name(entity.getTitle()) + .additionalInfo(getText(entity.getAdditionalInfo())) + .email(entity.getEmail()) + .country(entity.getCountry()) + .state(entity.getState()) + .city(entity.getCity()) + .address(entity.getAddress()) + .address2(entity.getAddress2()) + .zip(entity.getZip()) + .phone(entity.getPhone()) + .version(entity.getVersion()) + .build(); + } + + private static TenantFields toFields(Tenant entity) { + return TenantFields.builder() + .id(entity.getUuidId()) + .createdTime(entity.getCreatedTime()) + .name(entity.getTitle()) + .additionalInfo(getText(entity.getAdditionalInfo())) + .email(entity.getEmail()) + .country(entity.getCountry()) + .state(entity.getState()) + .city(entity.getCity()) + .address(entity.getAddress()) + .address2(entity.getAddress2()) + .zip(entity.getZip()) + .phone(entity.getPhone()) + .region(entity.getRegion()) + .version(entity.getVersion()) + .build(); + } + + private static TenantProfileFields toFields(TenantProfile tenantProfile) { + return TenantProfileFields.builder() + .id(tenantProfile.getUuidId()) + .createdTime(tenantProfile.getCreatedTime()) + .name(tenantProfile.getName()) + .isDefault(tenantProfile.isDefault()) + .build(); + } + + private static DeviceFields toFields(Device entity) { + return DeviceFields.builder() + .id(entity.getUuidId()) + .createdTime(entity.getCreatedTime()) + .customerId(getCustomerId(entity.getCustomerId())) + .name(entity.getName()) + .type(entity.getType()) + .deviceProfileId(entity.getDeviceProfileId().getId()) + .label(entity.getLabel()) + .additionalInfo(getText(entity.getAdditionalInfo())) + .version(entity.getVersion()) + .build(); + } + + private static AssetFields toFields(Asset entity) { + return AssetFields.builder() + .id(entity.getUuidId()) + .createdTime(entity.getCreatedTime()) + .customerId(getCustomerId(entity.getCustomerId())) + .name(entity.getName()) + .type(entity.getType()) + .assetProfileId(entity.getAssetProfileId().getId()) + .label(entity.getLabel()) + .additionalInfo(getText(entity.getAdditionalInfo())) + .version(entity.getVersion()) + .build(); + } + + private static EdgeFields toFields(Edge entity) { + return EdgeFields.builder() + .id(entity.getUuidId()) + .createdTime(entity.getCreatedTime()) + .customerId(getCustomerId(entity.getCustomerId())) + .name(entity.getName()) + .type(entity.getType()) + .label(entity.getLabel()) + .additionalInfo(getText(entity.getAdditionalInfo())) + .version(entity.getVersion()) + .build(); + } + + private static EntityViewFields toFields(EntityView entity) { + return EntityViewFields.builder() + .id(entity.getUuidId()) + .createdTime(entity.getCreatedTime()) + .customerId(getCustomerId(entity.getCustomerId())) + .name(entity.getName()) + .type(entity.getType()) + .additionalInfo(getText(entity.getAdditionalInfo())) + .version(entity.getVersion()) + .build(); + } + + private static UserFields toFields(User entity) { + return UserFields.builder() + .id(entity.getUuidId()) + .createdTime(entity.getCreatedTime()) + .customerId(getCustomerId(entity.getCustomerId())) + .firstName(entity.getFirstName()) + .lastName(entity.getLastName()) + .email(entity.getEmail()) + .phone(entity.getPhone()) + .additionalInfo(getText(entity.getAdditionalInfo())) + .version(entity.getVersion()) + .build(); + } + + private static DashboardFields toFields(Dashboard entity) { + return DashboardFields.builder() + .id(entity.getUuidId()) + .createdTime(entity.getCreatedTime()) + .name(entity.getTitle()) + .version(entity.getVersion()) + .build(); + } + + private static RuleChainFields toFields(RuleChain entity) { + return RuleChainFields.builder() + .id(entity.getUuidId()) + .createdTime(entity.getCreatedTime()) + .name(entity.getName()) + .additionalInfo(getText(entity.getAdditionalInfo())) + .version(entity.getVersion()) + .build(); + } + + private static RuleNodeFields toFields(RuleNode entity) { + return RuleNodeFields.builder() + .id(entity.getUuidId()) + .createdTime(entity.getCreatedTime()) + .name(entity.getName()) + .additionalInfo(getText(entity.getAdditionalInfo())) + .build(); + } + + private static WidgetTypeFields toFields(WidgetType entity) { + return WidgetTypeFields.builder() + .id(entity.getUuidId()) + .createdTime(entity.getCreatedTime()) + .name(entity.getName()) + .version(entity.getVersion()) + .build(); + } + + private static WidgetsBundleFields toFields(WidgetsBundle entity) { + return WidgetsBundleFields.builder() + .id(entity.getUuidId()) + .createdTime(entity.getCreatedTime()) + .name(entity.getName()) + .version(entity.getVersion()) + .build(); + } + + private static AssetProfileFields toFields(AssetProfile entity) { + return AssetProfileFields.builder() + .id(entity.getUuidId()) + .createdTime(entity.getCreatedTime()) + .name(entity.getName()) + .isDefault(entity.isDefault()) + .version(entity.getVersion()) + .build(); + } + + private static DeviceProfileFields toFields(DeviceProfile entity) { + return DeviceProfileFields.builder() + .id(entity.getUuidId()) + .createdTime(entity.getCreatedTime()) + .name(entity.getName()) + .type(DeviceProfileType.DEFAULT.name()) + .isDefault(entity.isDefault()) + .version(entity.getVersion()) + .build(); + } + + private static QueueStatsFields toFields(QueueStats entity) { + return QueueStatsFields.builder() + .id(entity.getUuidId()) + .createdTime(entity.getCreatedTime()) + .queueName(entity.getQueueName()) + .serviceId(entity.getServiceId()) + .build(); + } + + private static ApiUsageStateFields toFields(ApiUsageState entity) { + return ApiUsageStateFields.builder() + .id(entity.getUuidId()) + .createdTime(entity.getCreatedTime()) + .customerId(entity.getEntityId().getEntityType() == EntityType.CUSTOMER ? entity.getEntityId().getId() : null) + .entityId(entity.getEntityId()) + .transportState(entity.getTransportState()) + .dbStorageState(entity.getDbStorageState()) + .reExecState(entity.getReExecState()) + .jsExecState(entity.getJsExecState()) + .tbelExecState(entity.getTbelExecState()) + .emailExecState(entity.getEmailExecState()) + .smsExecState(entity.getSmsExecState()) + .alarmExecState(entity.getAlarmExecState()) + .version(entity.getVersion()) + .build(); + } + + public static String getText(JsonNode node) { + return node != null ? node.toString() : ""; + } + + private static UUID getCustomerId(CustomerId customerId) { + return (customerId != null && !customerId.getId().equals(CustomerId.NULL_UUID)) ? customerId.getId() : null; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/GenericFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/GenericFields.java new file mode 100644 index 0000000000..68366a6b4d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/GenericFields.java @@ -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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@NoArgsConstructor +public class GenericFields extends AbstractEntityFields { + + public GenericFields(UUID id, long createdTime, UUID tenantId, String name, Long version) { + super(id, createdTime, tenantId, name, version); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/ProfileAwareFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/ProfileAwareFields.java new file mode 100644 index 0000000000..4228755808 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/ProfileAwareFields.java @@ -0,0 +1,26 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import java.util.UUID; + +public interface ProfileAwareFields extends EntityFields { + + String getProfileName(); + + UUID getProfileId(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/QueueStatsFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/QueueStatsFields.java new file mode 100644 index 0000000000..cbc1ad4b8e --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/QueueStatsFields.java @@ -0,0 +1,42 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@SuperBuilder +public class QueueStatsFields extends AbstractEntityFields { + + private String queueName; + private String serviceId; + + @Override + public String getName() { + return queueName + '_' + serviceId; + } + + public QueueStatsFields(UUID id, long createdTime, UUID tenantId, String queueName, String serviceId) { + super(id, createdTime, tenantId); + this.queueName = queueName; + this.serviceId = serviceId; + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/RuleChainFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/RuleChainFields.java new file mode 100644 index 0000000000..a047eebd85 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/RuleChainFields.java @@ -0,0 +1,38 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.UUID; + +import static org.thingsboard.server.common.data.edqs.fields.FieldsUtil.getText; + +@Data +@NoArgsConstructor +@SuperBuilder +public class RuleChainFields extends AbstractEntityFields { + + private String additionalInfo; + + public RuleChainFields(UUID id, long createdTime, UUID tenantId, String name, Long version, JsonNode additionalInfo) { + super(id, createdTime, tenantId, name, version); + this.additionalInfo = getText(additionalInfo); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/RuleNodeFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/RuleNodeFields.java new file mode 100644 index 0000000000..8aa5b7f42f --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/RuleNodeFields.java @@ -0,0 +1,43 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.UUID; + +import static org.thingsboard.server.common.data.edqs.fields.FieldsUtil.getText; + +@Data +@NoArgsConstructor +@SuperBuilder +public class RuleNodeFields implements EntityFields { + + private UUID id; + private long createdTime; + private String name; + private String additionalInfo; + + public RuleNodeFields(UUID id, long createdTime, String name, JsonNode additionalInfo) { + this.id = id; + this.createdTime = createdTime; + this.name = name; + this.additionalInfo = getText(additionalInfo); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/TenantFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/TenantFields.java new file mode 100644 index 0000000000..342e2974a4 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/TenantFields.java @@ -0,0 +1,63 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.UUID; + +import static org.thingsboard.server.common.data.edqs.fields.FieldsUtil.getText; + +@Data +@NoArgsConstructor +@SuperBuilder +public class TenantFields extends AbstractEntityFields { + + private String additionalInfo; + private String country; + private String state; + private String city; + private String address; + private String address2; + private String zip; + private String phone; + private String email; + private String region; + + public TenantFields(UUID id, long createdTime, String name, Long version, + JsonNode additionalInfo, String country, String state, String city, String address, + String address2, String zip, String phone, String email, String region) { + super(id, createdTime, name, version); + this.additionalInfo = getText(additionalInfo); + this.country = country; + this.state = state; + this.city = city; + this.address = address; + this.address2 = address2; + this.zip = zip; + this.phone = phone; + this.email = email; + this.region = region; + } + + @Override + public UUID getTenantId() { + return getId(); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/TenantProfileFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/TenantProfileFields.java new file mode 100644 index 0000000000..b897bd1334 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/TenantProfileFields.java @@ -0,0 +1,36 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@SuperBuilder +public class TenantProfileFields extends AbstractEntityFields { + + private boolean isDefault; + + public TenantProfileFields(UUID id, long createdTime, String name, boolean isDefault) { + super(id, createdTime, TenantId.SYS_TENANT_ID.getId(), null, name, 0L); + this.isDefault = isDefault; + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/UserFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/UserFields.java new file mode 100644 index 0000000000..9863506ed4 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/UserFields.java @@ -0,0 +1,48 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.UUID; + +import static org.thingsboard.server.common.data.edqs.fields.FieldsUtil.getText; + +@Data +@NoArgsConstructor +@SuperBuilder +public class UserFields extends AbstractEntityFields { + + private String firstName; + private String lastName; + private String email; + private String phone; + private String additionalInfo; + + public UserFields(UUID id, long createdTime, UUID tenantId, UUID customerId, + Long version, String firstName, String lastName, String email, + String phone, JsonNode additionalInfo) { + super(id, createdTime, tenantId, customerId, version); + this.firstName = firstName; + this.lastName = lastName; + this.email = email; + this.phone = phone; + this.additionalInfo = getText(additionalInfo); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/WidgetTypeFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/WidgetTypeFields.java new file mode 100644 index 0000000000..4fd0079012 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/WidgetTypeFields.java @@ -0,0 +1,30 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.UUID; + +@NoArgsConstructor +@SuperBuilder +public class WidgetTypeFields extends AbstractEntityFields { + + public WidgetTypeFields(UUID id, long createdTime, UUID tenantId, String name, Long version) { + super(id, createdTime, tenantId, name, version); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/WidgetsBundleFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/WidgetsBundleFields.java new file mode 100644 index 0000000000..f2fb1df508 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/WidgetsBundleFields.java @@ -0,0 +1,30 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.fields; + +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.UUID; + +@NoArgsConstructor +@SuperBuilder +public class WidgetsBundleFields extends AbstractEntityFields { + + public WidgetsBundleFields(UUID id, long createdTime, UUID tenantId, String name, Long version) { + super(id, createdTime, tenantId, name, version); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/query/EdqsRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/query/EdqsRequest.java new file mode 100644 index 0000000000..e7d8b1df49 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/query/EdqsRequest.java @@ -0,0 +1,34 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.query; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.common.data.query.EntityDataQuery; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class EdqsRequest { + + private EntityDataQuery entityDataQuery; + private EntityCountQuery entityCountQuery; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/query/EdqsResponse.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/query/EdqsResponse.java new file mode 100644 index 0000000000..d4e53dea9f --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/query/EdqsResponse.java @@ -0,0 +1,35 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.query; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.EntityData; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class EdqsResponse { + + private PageData entityDataQueryResult; + private Long entityCountQueryResult; + private String error; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/query/QueryResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/query/QueryResult.java new file mode 100644 index 0000000000..67a05323a1 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/query/QueryResult.java @@ -0,0 +1,39 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.edqs.query; + +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.TsValue; + +import java.util.Collections; +import java.util.Map; + +@Data +@RequiredArgsConstructor +public class QueryResult { + + private final EntityId entityId; + private final Map> latest; + + public EntityData toOldEntityData() { + return new EntityData(entityId, latest, Collections.emptyMap(), Collections.emptyMap()); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/CalculatedFieldDebugEvent.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/CalculatedFieldDebugEvent.java new file mode 100644 index 0000000000..0424eabeb6 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/CalculatedFieldDebugEvent.java @@ -0,0 +1,94 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.event; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EventInfo; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.UUID; + +@ToString +@EqualsAndHashCode(callSuper = true) +public class CalculatedFieldDebugEvent extends Event { + + private static final long serialVersionUID = -7091690784759639853L; + + @Builder + private CalculatedFieldDebugEvent(TenantId tenantId, UUID entityId, String serviceId, UUID id, long ts, + CalculatedFieldId calculatedFieldId, EntityId eventEntity, UUID msgId, + String msgType, String arguments, String result, String error) { + super(tenantId, entityId, serviceId, id, ts); + this.calculatedFieldId = calculatedFieldId; + this.eventEntity = eventEntity; + this.msgId = msgId; + this.msgType = msgType; + this.arguments = arguments; + this.result = result; + this.error = error; + } + + @Getter + private final CalculatedFieldId calculatedFieldId; + @Getter + private final EntityId eventEntity; + @Getter + private final UUID msgId; + @Getter + private final String msgType; + @Getter + @Setter + private String arguments; + @Getter + @Setter + private String result; + @Getter + @Setter + private String error; + + @Override + public EventType getType() { + return EventType.DEBUG_CALCULATED_FIELD; + } + + @Override + public EventInfo toInfo(EntityType entityType) { + EventInfo eventInfo = super.toInfo(entityType); + var json = (ObjectNode) eventInfo.getBody(); + json.put("calculatedFieldId", calculatedFieldId.toString()); + if (eventEntity != null) { + json.put("entityId", eventEntity.getId().toString()) + .put("entityType", eventEntity.getEntityType().name()); + } + if (msgId != null) { + json.put("msgId", msgId.toString()); + } + putNotNull(json, "msgType", msgType); + putNotNull(json, "arguments", arguments); + putNotNull(json, "result", result); + putNotNull(json, "error", error); + return eventInfo; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/CalculatedFieldDebugEventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/CalculatedFieldDebugEventFilter.java new file mode 100644 index 0000000000..55ce036d9e --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/CalculatedFieldDebugEventFilter.java @@ -0,0 +1,56 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.event; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.StringUtils; + +@Data +@EqualsAndHashCode(callSuper = true) +@Schema +public class CalculatedFieldDebugEventFilter extends DebugEventFilter { + + @Schema(description = "String value representing the entity id in the event body", example = "57b6bafe-d600-423c-9267-fe31e5218986") + protected String entityId; + @Schema(description = "String value representing the entity type", allowableValues = "DEVICE") + protected String entityType; + @Schema(description = "String value representing the message id in the rule engine", example = "dcf44612-2ce4-4e5d-b462-ebb9c5628228") + protected String msgId; + @Schema(description = "String value representing the message type", example = "POST_TELEMETRY_REQUEST") + protected String msgType; + @Schema(description = "String value representing the arguments that were used in the calculation performed", + example = "{\"x\":{\"ts\":1739432016629,\"value\":20},\"y\":{\"ts\":1739429717656,\"value\":12}}") + protected String arguments; + @Schema(description = "String value representing the result of a calculation", + example = "{\"x + y\":54}") + protected String result; + + + @Override + public EventType getEventType() { + return EventType.DEBUG_CALCULATED_FIELD; + } + + @Override + public boolean isNotEmpty() { + return super.isNotEmpty() || !StringUtils.isEmpty(entityId) || !StringUtils.isEmpty(entityType) + || !StringUtils.isEmpty(msgId) || !StringUtils.isEmpty(msgType) + || !StringUtils.isEmpty(arguments) || !StringUtils.isEmpty(result); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java index 454d04f490..748771d1eb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java @@ -29,7 +29,8 @@ import io.swagger.v3.oas.annotations.media.Schema; @JsonSubTypes.Type(value = RuleChainDebugEventFilter.class, name = "DEBUG_RULE_CHAIN"), @JsonSubTypes.Type(value = ErrorEventFilter.class, name = "ERROR"), @JsonSubTypes.Type(value = LifeCycleEventFilter.class, name = "LC_EVENT"), - @JsonSubTypes.Type(value = StatisticsEventFilter.class, name = "STATS") + @JsonSubTypes.Type(value = StatisticsEventFilter.class, name = "STATS"), + @JsonSubTypes.Type(value = CalculatedFieldDebugEventFilter.class, name = "DEBUG_CALCULATED_FIELD") }) public interface EventFilter { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/EventType.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/EventType.java index af75a92ea6..ce529c81bc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/EventType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/EventType.java @@ -22,7 +22,8 @@ public enum EventType { LC_EVENT("lc_event", "LC_EVENT"), STATS("stats_event", "STATS"), DEBUG_RULE_NODE("rule_node_debug_event", "DEBUG_RULE_NODE", true), - DEBUG_RULE_CHAIN("rule_chain_debug_event", "DEBUG_RULE_CHAIN", true); + DEBUG_RULE_CHAIN("rule_chain_debug_event", "DEBUG_RULE_CHAIN", true), + DEBUG_CALCULATED_FIELD("cf_debug_event", "DEBUG_CALCULATED_FIELD", true); @Getter private final String table; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTask.java b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTask.java index caeef6bd2f..ed02d22a74 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTask.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTask.java @@ -81,6 +81,10 @@ public class HousekeeperTask implements Serializable { return new TenantEntitiesDeletionHousekeeperTask(tenantId, entityType); } + public static HousekeeperTask deleteCalculatedFields(TenantId tenantId, EntityId entityId) { + return new HousekeeperTask(tenantId, entityId, HousekeeperTaskType.DELETE_CALCULATED_FIELDS); + } + @JsonIgnore public String getDescription() { return taskType.getDescription() + " for " + entityId.getEntityType().getNormalName().toLowerCase() + " " + entityId.getId(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTaskType.java b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTaskType.java index 1331b175ac..ef217debc3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTaskType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTaskType.java @@ -30,7 +30,8 @@ public enum HousekeeperTaskType { DELETE_ALARMS("alarms deletion"), UNASSIGN_ALARMS("alarms unassigning"), DELETE_TENANT_ENTITIES("tenant entities deletion"), - DELETE_ENTITIES("entities deletion"); + DELETE_ENTITIES("entities deletion"), + DELETE_CALCULATED_FIELDS("calculated fields deletion"); private final String description; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/CalculatedFieldId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/CalculatedFieldId.java new file mode 100644 index 0000000000..e17a066d88 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/CalculatedFieldId.java @@ -0,0 +1,47 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.id; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import org.thingsboard.server.common.data.EntityType; + +import java.io.Serial; +import java.util.UUID; + +@Schema +public class CalculatedFieldId extends UUIDBased implements EntityId { + + @Serial + private static final long serialVersionUID = 1L; + + @JsonCreator + public CalculatedFieldId(@JsonProperty("id") UUID id) { + super(id); + } + + public static CalculatedFieldId fromString(String calculatedFieldId) { + return new CalculatedFieldId(UUID.fromString(calculatedFieldId)); + } + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "CALCULATED_FIELD", allowableValues = "CALCULATED_FIELD") + @Override + public EntityType getEntityType() { + return EntityType.CALCULATED_FIELD; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/CalculatedFieldLinkId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/CalculatedFieldLinkId.java new file mode 100644 index 0000000000..6a0c680bb6 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/CalculatedFieldLinkId.java @@ -0,0 +1,45 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.id; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import org.thingsboard.server.common.data.EntityType; + +import java.util.UUID; + +@Schema +public class CalculatedFieldLinkId extends UUIDBased implements EntityId { + + private static final long serialVersionUID = 1L; + + @JsonCreator + public CalculatedFieldLinkId(@JsonProperty("id") UUID id) { + super(id); + } + + public static CalculatedFieldLinkId fromString(String calculatedFieldLinkId) { + return new CalculatedFieldLinkId(UUID.fromString(calculatedFieldLinkId)); + } + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "CALCULATED_FIELD_LINK", allowableValues = "CALCULATED_FIELD_LINK") + @Override + public EntityType getEntityType() { + return EntityType.CALCULATED_FIELD_LINK; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java index 7295f9795a..f5dd4b12a0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java @@ -113,6 +113,10 @@ public class EntityIdFactory { return new DomainId(uuid); case MOBILE_APP_BUNDLE: return new MobileAppBundleId(uuid); + case CALCULATED_FIELD: + return new CalculatedFieldId(uuid); + case CALCULATED_FIELD_LINK: + return new CalculatedFieldLinkId(uuid); } throw new IllegalArgumentException("EntityType " + type + " is not supported!"); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/UserAuthSettingsId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/UserAuthSettingsId.java index f17151dea9..a57ee7ee48 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/UserAuthSettingsId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/UserAuthSettingsId.java @@ -15,11 +15,15 @@ */ package org.thingsboard.server.common.data.id; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + import java.util.UUID; public class UserAuthSettingsId extends UUIDBased { - public UserAuthSettingsId(UUID id) { + @JsonCreator + public UserAuthSettingsId(@JsonProperty("id") UUID id) { super(id); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/TimeseriesSaveResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/TimeseriesSaveResult.java new file mode 100644 index 0000000000..233eb8df73 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/TimeseriesSaveResult.java @@ -0,0 +1,30 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.kv; + +import lombok.Data; + +import java.util.List; + +@Data(staticConstructor = "of") +public class TimeseriesSaveResult { + + public static final TimeseriesSaveResult EMPTY = new TimeseriesSaveResult(0, null); + + private final Integer dataPoints; + private final List versions; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java b/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java index a25d12577f..db7f14171b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java @@ -43,7 +43,8 @@ public enum LimitedApi { TRANSPORT_MESSAGES_PER_GATEWAY("transport messages per gateway", false), TRANSPORT_MESSAGES_PER_GATEWAY_DEVICE("transport messages per gateway device", false), EMAILS("emails sending", true), - WS_SUBSCRIPTIONS("WS subscriptions", false); + WS_SUBSCRIPTIONS("WS subscriptions", false), + CALCULATED_FIELD_DEBUG_EVENTS("calculated field debug events", true); private Function configExtractor; @Getter diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/QuerySecurityContext.java b/common/data/src/main/java/org/thingsboard/server/common/data/permission/QueryContext.java similarity index 72% rename from dao/src/main/java/org/thingsboard/server/dao/sql/query/QuerySecurityContext.java rename to common/data/src/main/java/org/thingsboard/server/common/data/permission/QueryContext.java index bb5fdddb9d..ace36a1f37 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/QuerySecurityContext.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/permission/QueryContext.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao.sql.query; +package org.thingsboard.server.common.data.permission; import lombok.AllArgsConstructor; import lombok.Getter; @@ -21,8 +21,12 @@ import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + @AllArgsConstructor -public class QuerySecurityContext { +public class QueryContext { @Getter private final TenantId tenantId; @@ -33,7 +37,14 @@ public class QuerySecurityContext { @Getter private final boolean ignorePermissionCheck; - public QuerySecurityContext(TenantId tenantId, CustomerId customerId, EntityType entityType) { + @Getter + private final Map relatedParentIdMap = new HashMap<>(); + + public QueryContext(TenantId tenantId, CustomerId customerId, EntityType entityType) { this(tenantId, customerId, entityType, false); } + + public boolean isTenantUser() { + return customerId == null || customerId.isNullUid(); + } } \ No newline at end of file diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java index 103bd90eac..71a8e8ef33 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java @@ -17,7 +17,7 @@ package org.thingsboard.server.common.data.query; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Getter; +import lombok.Data; import lombok.NoArgsConstructor; import lombok.ToString; import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; @@ -29,7 +29,7 @@ import java.util.List; @Builder @NoArgsConstructor @AllArgsConstructor -@Getter +@Data @ToString public class AlarmCountQuery extends EntityCountQuery { private long startTs; @@ -40,4 +40,9 @@ public class AlarmCountQuery extends EntityCountQuery { private List severityList; private boolean searchPropagatedAlarms; private UserId assigneeId; + + public AlarmCountQuery(EntityFilter entityFilter) { + super(entityFilter); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/DynamicValue.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/DynamicValue.java index 9ae56a1d60..8c4ae1e8ac 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/DynamicValue.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/DynamicValue.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.common.data.query; -import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Data; import lombok.RequiredArgsConstructor; import org.thingsboard.server.common.data.validation.NoXss; @@ -26,7 +25,6 @@ import java.io.Serializable; @RequiredArgsConstructor public class DynamicValue implements Serializable { - @JsonIgnore private T resolvedValue; private final DynamicValueSourceType sourceType; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java index e2e7e18d9f..12b5331651 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.query; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; import lombok.ToString; @@ -24,6 +25,7 @@ import java.util.List; @Schema @ToString +@JsonIgnoreProperties(ignoreUnknown = true) public class EntityCountQuery { @Getter diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityData.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityData.java index 1519e3910d..328882bbd0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityData.java @@ -16,20 +16,22 @@ package org.thingsboard.server.common.data.query; import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.AllArgsConstructor; import lombok.Data; -import lombok.RequiredArgsConstructor; +import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.id.EntityId; import java.util.Map; @Data -@RequiredArgsConstructor +@AllArgsConstructor +@NoArgsConstructor public class EntityData { - private final EntityId entityId; - private final Map> latest; - private final Map timeseries; - private final Map aggLatest; + private EntityId entityId; + private Map> latest; + private Map timeseries; + private Map aggLatest; public EntityData(EntityId entityId, Map> latest, Map timeseries) { this(entityId, latest, timeseries, null); @@ -44,4 +46,5 @@ public class EntityData { aggLatest.clear(); } } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java index 4be9633d96..5507f53f08 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java @@ -39,7 +39,8 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; @JsonSubTypes.Type(value = AssetSearchQueryFilter.class, name = "assetSearchQuery"), @JsonSubTypes.Type(value = DeviceSearchQueryFilter.class, name = "deviceSearchQuery"), @JsonSubTypes.Type(value = EntityViewSearchQueryFilter.class, name = "entityViewSearchQuery"), - @JsonSubTypes.Type(value = EdgeSearchQueryFilter.class, name = "edgeSearchQuery")}) + @JsonSubTypes.Type(value = EdgeSearchQueryFilter.class, name = "edgeSearchQuery") +}) public interface EntityFilter { @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/queue/ProcessingStrategyType.java b/common/data/src/main/java/org/thingsboard/server/common/data/queue/ProcessingStrategyType.java index 5b8c86d7d1..ca63d34a84 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/queue/ProcessingStrategyType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/queue/ProcessingStrategyType.java @@ -13,21 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -/** - * Copyright © 2016-2020 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. - */ package org.thingsboard.server.common.data.queue; public enum ProcessingStrategyType { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/queue/QueueConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/queue/QueueConfig.java index 9683967659..294a7ee2c2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/queue/QueueConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/queue/QueueConfig.java @@ -15,10 +15,22 @@ */ package org.thingsboard.server.common.data.queue; +import lombok.Data; + public interface QueueConfig { boolean isConsumerPerPartition(); int getPollInterval(); + static QueueConfig of(boolean consumerPerPartition, long pollInterval) { + return new BasicQueueConfig(consumerPerPartition, (int) pollInterval); + } + + @Data + class BasicQueueConfig implements QueueConfig { + private final boolean consumerPerPartition; + private final int pollInterval; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelation.java b/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelation.java index 7cbedcbacb..8980d0e634 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelation.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelation.java @@ -25,6 +25,8 @@ import lombok.ToString; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.BaseDataWithAdditionalInfo; import org.thingsboard.server.common.data.HasVersion; +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.edqs.EdqsObject; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.validation.Length; @@ -34,7 +36,7 @@ import java.io.Serializable; @Schema @EqualsAndHashCode(exclude = "additionalInfoBytes") @ToString(exclude = {"additionalInfoBytes"}) -public class EntityRelation implements HasVersion, Serializable { +public class EntityRelation implements HasVersion, Serializable, EdqsObject { private static final long serialVersionUID = 2807343040519543363L; @@ -107,7 +109,7 @@ public class EntityRelation implements HasVersion, Serializable { return typeGroup; } - @Schema(description = "Additional parameters of the relation",implementation = com.fasterxml.jackson.databind.JsonNode.class) + @Schema(description = "Additional parameters of the relation", implementation = com.fasterxml.jackson.databind.JsonNode.class) public JsonNode getAdditionalInfo() { return BaseDataWithAdditionalInfo.getJson(() -> additionalInfo, () -> additionalInfoBytes); } @@ -116,4 +118,19 @@ public class EntityRelation implements HasVersion, Serializable { BaseDataWithAdditionalInfo.setJson(addInfo, json -> this.additionalInfo = json, bytes -> this.additionalInfoBytes = bytes); } + @JsonIgnore + public String key() { + return "r_" + from + "_" + to + "_" + typeGroup + "_" + type; + } + + @Override + public Long version() { + return version; + } + + @Override + public ObjectType type() { + return ObjectType.RELATION; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/DeviceExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/DeviceExportData.java index bfa82caa2b..98948ca642 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/DeviceExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/DeviceExportData.java @@ -38,4 +38,5 @@ public class DeviceExportData extends EntityExportData { public boolean hasCredentials() { return credentials != null; } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java index 7bc9a63c29..072be6acf2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java @@ -26,6 +26,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo.As; import lombok.Data; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.sync.JsonTbEntity; @@ -55,6 +56,8 @@ public class EntityExportData> { public static final Comparator attrComparator = Comparator .comparing(AttributeExportData::getKey).thenComparing(AttributeExportData::getLastUpdateTs); + public static final Comparator calculatedFieldsComparator = Comparator.comparing(CalculatedField::getName); + @JsonProperty(index = 2) @JsonTbEntity private E entity; @@ -65,6 +68,9 @@ public class EntityExportData> { private List relations; @JsonProperty(index = 101) private Map> attributes; + @JsonProperty(index = 102) + @JsonIgnoreProperties({"id", "entityId", "createdTime", "version"}) + private List calculatedFields; public EntityExportData sort() { if (relations != null && !relations.isEmpty()) { @@ -73,6 +79,9 @@ public class EntityExportData> { if (attributes != null && !attributes.isEmpty()) { attributes.values().forEach(list -> list.sort(attrComparator)); } + if (calculatedFields != null && !calculatedFields.isEmpty()) { + calculatedFields.sort(calculatedFieldsComparator); + } return this; } @@ -96,4 +105,9 @@ public class EntityExportData> { return relations != null; } + @JsonIgnore + public boolean hasCalculatedFields() { + return calculatedFields != null; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportSettings.java index 46dc6860ee..078ca29e28 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportSettings.java @@ -25,7 +25,10 @@ import lombok.NoArgsConstructor; @NoArgsConstructor @Builder public class EntityExportSettings { + private boolean exportRelations; private boolean exportAttributes; private boolean exportCredentials; + private boolean exportCalculatedFields; + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityImportSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityImportSettings.java index 2abdb55b65..d49801537c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityImportSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityImportSettings.java @@ -25,8 +25,11 @@ import lombok.NoArgsConstructor; @NoArgsConstructor @Builder public class EntityImportSettings { + private boolean findExistingByName; private boolean updateRelations; private boolean saveAttributes; private boolean saveCredentials; + private boolean saveCalculatedFields; + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/EntityDataInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/EntityDataInfo.java index 0b8f392472..d55a575a75 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/EntityDataInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/EntityDataInfo.java @@ -26,4 +26,5 @@ public class EntityDataInfo { boolean hasRelations; boolean hasAttributes; boolean hasCredentials; + boolean hasCalculatedFields; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateConfig.java index 5a02b18364..6d1ac2283b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateConfig.java @@ -17,13 +17,18 @@ package org.thingsboard.server.common.data.sync.vc.request.create; import lombok.Data; +import java.io.Serial; import java.io.Serializable; @Data public class VersionCreateConfig implements Serializable { + + @Serial private static final long serialVersionUID = 1223723167716612772L; private boolean saveRelations; private boolean saveAttributes; private boolean saveCredentials; + private boolean saveCalculatedFields; + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadConfig.java index 0ae263dada..7f3ce89372 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadConfig.java @@ -23,5 +23,6 @@ public class VersionLoadConfig { private boolean loadRelations; private boolean loadAttributes; private boolean loadCredentials; + private boolean loadCalculatedFields; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index f62b3aaa9f..f256b02d9a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.tenant.profile; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -135,6 +136,19 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura private double warnThreshold; + @Schema(example = "5") + private long maxCalculatedFieldsPerEntity = 5; + @Schema(example = "10") + private long maxArgumentsPerCF = 10; + @Builder.Default + @Min(value = 1, message = "must be at least 1") + @Schema(example = "1000") + private long maxDataPointsPerRollingArg = 1000; + @Schema(example = "32") + private long maxStateSizeInKBytes = 32; + @Schema(example = "2") + private long maxSingleValueArgumentSizeInKBytes = 2; + @Override public long getProfileThreshold(ApiUsageRecordKey key) { return switch (key) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/TenantProfileData.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/TenantProfileData.java index b1f6c27fd8..44ca79cabb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/TenantProfileData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/TenantProfileData.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.tenant.profile; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import lombok.Data; import java.io.Serializable; @@ -27,6 +28,7 @@ public class TenantProfileData implements Serializable { private static final long serialVersionUID = -3642550257035920976L; + @Valid @Schema(description = "Complex JSON object that contains profile settings: max devices, max assets, rate limits, etc.") private TenantProfileConfiguration configuration; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java b/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java index 390c1f234f..71c5256203 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.util; import java.util.Collection; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -75,4 +76,23 @@ public class CollectionsUtil { return isEmpty(collection) || collection.contains(element); } + public static HashSet concat(Set set1, Set set2) { + HashSet result = new HashSet<>(); + result.addAll(set1); + result.addAll(set2); + return result; + } + + public static boolean isOneOf(V value, V... others) { + if (value == null) { + return false; + } + for (V other : others) { + if (value.equals(other)) { + return true; + } + } + return false; + } + } diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index 6a7482a3c1..023ac00634 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -42,6 +42,8 @@ enum EdgeVersion { V_3_8_0 = 8; V_3_9_0 = 9; V_4_0_0 = 10; + + V_LATEST = 999; } /** @@ -303,7 +305,9 @@ message NotificationTemplateUpdateMsg { optional string entity = 4; } +// DEPRECATED. FOR REMOVAL message RuleChainMetadataRequestMsg { + option deprecated = true; int64 ruleChainIdMSB = 1; int64 ruleChainIdLSB = 2; } @@ -321,22 +325,30 @@ message RelationRequestMsg { string entityType = 3; } +// DEPRECATED. FOR REMOVAL message UserCredentialsRequestMsg { + option deprecated = true; int64 userIdMSB = 1; int64 userIdLSB = 2; } +// DEPRECATED. FOR REMOVAL message DeviceCredentialsRequestMsg { + option deprecated = true; int64 deviceIdMSB = 1; int64 deviceIdLSB = 2; } +// DEPRECATED. FOR REMOVAL message WidgetBundleTypesRequestMsg { + option deprecated = true; int64 widgetBundleIdMSB = 1; int64 widgetBundleIdLSB = 2; } +// DEPRECATED. FOR REMOVAL message EntityViewsRequestMsg { + option deprecated = true; int64 entityIdMSB = 1; int64 entityIdLSB = 2; string entityType = 3; @@ -394,14 +406,14 @@ message UplinkMsg { repeated DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg = 4; repeated AlarmUpdateMsg alarmUpdateMsg = 5; repeated RelationUpdateMsg relationUpdateMsg = 6; - repeated RuleChainMetadataRequestMsg ruleChainMetadataRequestMsg = 7; + repeated RuleChainMetadataRequestMsg ruleChainMetadataRequestMsg = 7 [deprecated = true]; repeated AttributesRequestMsg attributesRequestMsg = 8; repeated RelationRequestMsg relationRequestMsg = 9; - repeated UserCredentialsRequestMsg userCredentialsRequestMsg = 10; - repeated DeviceCredentialsRequestMsg deviceCredentialsRequestMsg = 11; + repeated UserCredentialsRequestMsg userCredentialsRequestMsg = 10 [deprecated = true]; + repeated DeviceCredentialsRequestMsg deviceCredentialsRequestMsg = 11 [deprecated = true]; repeated DeviceRpcCallMsg deviceRpcCallMsg = 12; - repeated WidgetBundleTypesRequestMsg widgetBundleTypesRequestMsg = 14; - repeated EntityViewsRequestMsg entityViewsRequestMsg = 15; + repeated WidgetBundleTypesRequestMsg widgetBundleTypesRequestMsg = 14 [deprecated = true]; + repeated EntityViewsRequestMsg entityViewsRequestMsg = 15 [deprecated = true]; repeated AssetUpdateMsg assetUpdateMsg = 16; repeated DashboardUpdateMsg dashboardUpdateMsg = 17; repeated EntityViewUpdateMsg entityViewUpdateMsg = 18; @@ -409,6 +421,8 @@ message UplinkMsg { repeated DeviceProfileUpdateMsg deviceProfileUpdateMsg = 20; repeated ResourceUpdateMsg resourceUpdateMsg = 21; repeated AlarmCommentUpdateMsg alarmCommentUpdateMsg = 22; + repeated RuleChainUpdateMsg ruleChainUpdateMsg = 23; + repeated RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg = 24; } message UplinkResponseMsg { @@ -427,7 +441,7 @@ message DownlinkMsg { int32 downlinkMsgId = 1; SyncCompletedMsg syncCompletedMsg = 2; repeated EntityDataProto entityData = 3; - repeated DeviceCredentialsRequestMsg deviceCredentialsRequestMsg = 4; + repeated DeviceCredentialsRequestMsg deviceCredentialsRequestMsg = 4 [deprecated = true]; repeated DeviceUpdateMsg deviceUpdateMsg = 5; repeated DeviceProfileUpdateMsg deviceProfileUpdateMsg = 6; repeated DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg = 7; diff --git a/common/edqs/pom.xml b/common/edqs/pom.xml new file mode 100644 index 0000000000..f7106e56fd --- /dev/null +++ b/common/edqs/pom.xml @@ -0,0 +1,97 @@ + + + 4.0.0 + + org.thingsboard + 4.0.0-SNAPSHOT + common + + org.thingsboard.common + edqs + jar + + ThingsBoard EDQS API + https://thingsboard.io + + + UTF-8 + ${basedir}/../.. + + + + + org.rocksdb + rocksdbjni + + + org.thingsboard.common + proto + + + org.thingsboard.common + data + + + org.thingsboard.common + util + + + org.thingsboard.common + message + + + org.thingsboard.common + stats + + + org.thingsboard.common + cluster-api + + + org.thingsboard.common + queue + + + org.springframework.boot + spring-boot-starter-web + + + com.github.ben-manes.caffeine + caffeine + + + org.springframework + spring-context-support + + + org.springframework.boot + spring-boot-autoconfigure + + + + + + thingsboard-repo-deploy + ThingsBoard Repo Deployment + https://repo.thingsboard.io/artifactory/libs-release-public + + + + diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/ApiUsageStateData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/ApiUsageStateData.java new file mode 100644 index 0000000000..f7cd51fc38 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/ApiUsageStateData.java @@ -0,0 +1,46 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data; + +import lombok.ToString; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.fields.ApiUsageStateFields; + +import java.util.UUID; + +@ToString(callSuper = true) +public class ApiUsageStateData extends BaseEntityData { + + public ApiUsageStateData(UUID entityId) { + super(entityId); + } + + @Override + public EntityType getEntityType() { + return EntityType.API_USAGE_STATE; + } + + @Override + public String getEntityName() { + return getEntityOwnerName(); + } + + @Override + public String getEntityOwnerName() { + return repo.getOwnerName(fields.getEntityId()); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/AssetData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/AssetData.java new file mode 100644 index 0000000000..52e6151ff3 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/AssetData.java @@ -0,0 +1,36 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data; + +import lombok.ToString; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.fields.AssetFields; + +import java.util.UUID; + +@ToString(callSuper = true) +public class AssetData extends ProfileAwareData { + + public AssetData(UUID id) { + super(id); + } + + @Override + public EntityType getEntityType() { + return EntityType.ASSET; + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java new file mode 100644 index 0000000000..10ee17fc75 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java @@ -0,0 +1,180 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.fields.EntityFields; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.edqs.data.dp.BoolDataPoint; +import org.thingsboard.server.common.data.edqs.DataPoint; +import org.thingsboard.server.edqs.data.dp.LongDataPoint; +import org.thingsboard.server.edqs.data.dp.StringDataPoint; +import org.thingsboard.server.edqs.query.DataKey; +import org.thingsboard.server.edqs.repo.TenantRepo; + +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@ToString +public abstract class BaseEntityData implements EntityData { + + @Getter + private final UUID id; + @Getter + protected final Map serverAttrMap; + @Getter + private final Map tMap; + + @Getter + @Setter + private volatile UUID customerId; + + @Setter + protected TenantRepo repo; + + @Getter + @Setter + protected volatile T fields; + + public BaseEntityData(UUID id) { + this.id = id; + this.serverAttrMap = new ConcurrentHashMap<>(); + this.tMap = new ConcurrentHashMap<>(); + } + + @Override + public DataPoint getAttr(Integer keyId, EntityKeyType entityKeyType) { + return switch (entityKeyType) { + case ATTRIBUTE, SERVER_ATTRIBUTE -> serverAttrMap.get(keyId); + default -> null; + }; + } + + @Override + public boolean putAttr(Integer keyId, AttributeScope scope, DataPoint value) { + return serverAttrMap.put(keyId, value) == null; + } + + @Override + public boolean removeAttr(Integer keyId, AttributeScope scope) { + return serverAttrMap.remove(keyId) != null; + } + + @Override + public DataPoint getTs(Integer keyId) { + return tMap.get(keyId); + } + + @Override + public boolean putTs(Integer keyId, DataPoint value) { + return tMap.put(keyId, value) == null; + } + + @Override + public boolean removeTs(Integer keyId) { + return tMap.remove(keyId) != null; + } + + @Override + public EntityType getOwnerType() { + return customerId != null ? EntityType.CUSTOMER : EntityType.TENANT; + } + + @Override + public DataPoint getDataPoint(DataKey key, QueryContext ctx) { + return switch (key.type()) { + case TIME_SERIES -> getTs(key.keyId()); + case ATTRIBUTE, SERVER_ATTRIBUTE, CLIENT_ATTRIBUTE, SHARED_ATTRIBUTE -> getAttr(key.keyId(), key.type()); + case ENTITY_FIELD -> getField(key, ctx); + default -> throw new RuntimeException(key.type() + " not supported"); + }; + } + + private DataPoint getField(DataKey newKey, QueryContext ctx) { + if (fields == null) { + return null; + } + String key = newKey.key(); + return switch (key) { + case "createdTime" -> new LongDataPoint(System.currentTimeMillis(), fields.getCreatedTime()); + case "edgeTemplate" -> new BoolDataPoint(System.currentTimeMillis(), fields.isEdgeTemplate()); + case "parentId" -> new StringDataPoint(System.currentTimeMillis(), getRelatedParentId(ctx)); + default -> new StringDataPoint(System.currentTimeMillis(), getField(key), false); + }; + } + + @Override + public String getField(String name) { + if (fields == null) { + return null; + } + return switch (name) { + case "name" -> getEntityName(); + case "ownerName" -> getEntityOwnerName(); + case "ownerType" -> customerId != null ? EntityType.CUSTOMER.name() : EntityType.TENANT.name(); + case "entityType" -> Optional.ofNullable(getEntityType()).map(EntityType::name).orElse(""); + default -> fields.getAsString(name); + }; + } + + public String getEntityOwnerName() { + return repo.getOwnerName(getCustomerId() == null || CustomerId.NULL_UUID.equals(getCustomerId()) ? null : + new CustomerId(getCustomerId())); + } + + public String getEntityName() { + return getFields().getName(); + } + + private String getRelatedParentId(QueryContext ctx) { + return Optional.ofNullable(ctx.getRelatedParentIdMap().get(getId())) + .map(UUID::toString) + .orElse(""); + } + + @Override + public EntityType getEntityType() { + return null; + } + + @Override + public boolean isEmpty() { + return fields == null; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + BaseEntityData that = (BaseEntityData) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/CustomerData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/CustomerData.java new file mode 100644 index 0000000000..bf2a3f6da7 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/CustomerData.java @@ -0,0 +1,62 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.fields.CustomerFields; + +import java.util.Collection; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +public class CustomerData extends BaseEntityData { + + private final ConcurrentMap>> entitiesById = new ConcurrentHashMap<>(); + + public CustomerData(UUID entityId) { + super(entityId); + } + + @Override + public EntityType getEntityType() { + return EntityType.CUSTOMER; + } + + public Collection> getEntities(EntityType entityType) { + var map = entitiesById.get(entityType); + if (map == null) { + return Collections.emptyList(); + } else { + return map.values(); + } + } + + public void addOrUpdate(EntityData ed) { + entitiesById.computeIfAbsent(ed.getEntityType(), et -> new ConcurrentHashMap<>()).put(ed.getId(), ed); + } + + public boolean remove(EntityData ed) { + var map = entitiesById.get(ed.getEntityType()); + if (map != null) { + return map.remove(ed.getId()) != null; + } else { + return false; + } + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/DeviceData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/DeviceData.java new file mode 100644 index 0000000000..3a3e5c5792 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/DeviceData.java @@ -0,0 +1,86 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data; + +import lombok.ToString; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.fields.DeviceFields; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.edqs.DataPoint; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@ToString(callSuper = true) +public class DeviceData extends ProfileAwareData { + + private final Map clientAttrMap; + private final Map sharedAttrMap; + + public DeviceData(UUID entityId) { + super(entityId); + this.clientAttrMap = new ConcurrentHashMap<>(); + this.sharedAttrMap = new ConcurrentHashMap<>(); + } + + @Override + public EntityType getEntityType() { + return EntityType.DEVICE; + } + + @Override + public DataPoint getAttr(Integer keyId, EntityKeyType entityKeyType) { + return switch (entityKeyType) { + case ATTRIBUTE -> getAttributeDataPoint(keyId); + case SERVER_ATTRIBUTE -> serverAttrMap.get(keyId); + case CLIENT_ATTRIBUTE -> clientAttrMap.get(keyId); + case SHARED_ATTRIBUTE -> sharedAttrMap.get(keyId); + default -> throw new RuntimeException(entityKeyType + " not implemented"); + }; + } + + @Override + public boolean putAttr(Integer keyId, AttributeScope scope, DataPoint value) { + return switch (scope) { + case SERVER_SCOPE -> serverAttrMap.put(keyId, value) == null; + case CLIENT_SCOPE -> clientAttrMap.put(keyId, value) == null; + case SHARED_SCOPE -> sharedAttrMap.put(keyId, value) == null; + }; + } + + @Override + public boolean removeAttr(Integer keyId, AttributeScope scope) { + return switch (scope) { + case SERVER_SCOPE -> serverAttrMap.remove(keyId) != null; + case CLIENT_SCOPE -> clientAttrMap.remove(keyId) != null; + case SHARED_SCOPE -> sharedAttrMap.remove(keyId) != null; + }; + } + + private DataPoint getAttributeDataPoint(Integer keyId) { + DataPoint dp = serverAttrMap.get(keyId); + if (dp == null) { + dp = sharedAttrMap.get(keyId); + if (dp == null) { + dp = clientAttrMap.get(keyId); + } + } + return dp; + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/EntityData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/EntityData.java new file mode 100644 index 0000000000..53ee73f638 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/EntityData.java @@ -0,0 +1,65 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data; + +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.fields.EntityFields; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.edqs.DataPoint; +import org.thingsboard.server.edqs.query.DataKey; +import org.thingsboard.server.edqs.repo.TenantRepo; + +import java.util.UUID; + +public interface EntityData { + + UUID getId(); + + EntityType getEntityType(); + + UUID getCustomerId(); + + void setCustomerId(UUID customerId); + + void setRepo(TenantRepo repo); + + T getFields(); + + void setFields(T fields); + + DataPoint getAttr(Integer keyId, EntityKeyType entityKeyType); + + boolean putAttr(Integer keyId, AttributeScope scope, DataPoint value); + + boolean removeAttr(Integer keyId, AttributeScope scope); + + DataPoint getTs(Integer keyId); + + boolean putTs(Integer keyId, DataPoint value); + + boolean removeTs(Integer keyId); + + EntityType getOwnerType(); + + DataPoint getDataPoint(DataKey key, QueryContext queryContext); + + String getField(String name); + + boolean isEmpty(); + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/EntityProfileData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/EntityProfileData.java new file mode 100644 index 0000000000..a13c70557b --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/EntityProfileData.java @@ -0,0 +1,39 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data; + +import lombok.ToString; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.fields.EntityFields; + +import java.util.UUID; + +@ToString(callSuper = true) +public class EntityProfileData extends BaseEntityData { + + private final EntityType entityType; + + public EntityProfileData(UUID entityId, EntityType entityType) { + super(entityId); + this.entityType = entityType; + } + + @Override + public EntityType getEntityType() { + return entityType; + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/GenericData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/GenericData.java new file mode 100644 index 0000000000..344a2b7049 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/GenericData.java @@ -0,0 +1,38 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data; + +import lombok.ToString; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.fields.EntityFields; + +import java.util.UUID; + +@ToString(callSuper = true) +public class GenericData extends BaseEntityData { + + private final EntityType entityType; + + public GenericData(EntityType entityType, UUID entityId) { + super(entityId); + this.entityType = entityType; + } + + @Override + public EntityType getEntityType() { + return entityType; + } +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/ProfileAwareData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/ProfileAwareData.java new file mode 100644 index 0000000000..bab0f962c4 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/ProfileAwareData.java @@ -0,0 +1,28 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data; + +import org.thingsboard.server.common.data.edqs.fields.ProfileAwareFields; + +import java.util.UUID; + +public abstract class ProfileAwareData extends BaseEntityData { + + public ProfileAwareData(UUID id) { + super(id); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/RelationData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/RelationData.java new file mode 100644 index 0000000000..e006def91b --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/RelationData.java @@ -0,0 +1,26 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; + +import java.util.UUID; + +public record RelationData(UUID fromId, EntityType fromType, UUID toId, EntityType toType, String type, + RelationTypeGroup typeGroup) { + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/RelationInfo.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/RelationInfo.java new file mode 100644 index 0000000000..5eb5d7aee4 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/RelationInfo.java @@ -0,0 +1,26 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data; + +import lombok.Data; + +@Data +public class RelationInfo { + + private final String type; + private final EntityData target; + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/RelationsRepo.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/RelationsRepo.java new file mode 100644 index 0000000000..c094e37261 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/RelationsRepo.java @@ -0,0 +1,62 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data; + +import lombok.NoArgsConstructor; + +import java.util.Collections; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +@NoArgsConstructor +public class RelationsRepo { + + private final ConcurrentMap> fromRelations = new ConcurrentHashMap<>(); + private final ConcurrentMap> toRelations = new ConcurrentHashMap<>(); + + public boolean add(EntityData from, EntityData to, String type) { + boolean addedFromRelation = fromRelations.computeIfAbsent(from.getId(), k -> ConcurrentHashMap.newKeySet()).add(new RelationInfo(type, to)); + boolean addedToRelation = toRelations.computeIfAbsent(to.getId(), k -> ConcurrentHashMap.newKeySet()).add(new RelationInfo(type, from)); + return addedFromRelation || addedToRelation; + } + + public Set getFrom(UUID entityId) { + var result = fromRelations.get(entityId); + return result == null ? Collections.emptySet() : result; + } + + public Set getTo(UUID entityId) { + var result = toRelations.get(entityId); + return result == null ? Collections.emptySet() : result; + } + + public boolean remove(UUID from, UUID to, String type) { + boolean removedFromRelation = false; + boolean removedToRelation = false; + Set fromRelations = this.fromRelations.get(from); + if (fromRelations != null) { + removedFromRelation = fromRelations.removeIf(relationInfo -> relationInfo.getTarget().getId().equals(to) && relationInfo.getType().equals(type)); + } + Set toRelations = this.toRelations.get(to); + if (toRelations != null) { + removedToRelation = toRelations.removeIf(relationInfo -> relationInfo.getTarget().getId().equals(from) && relationInfo.getType().equals(type)); + } + return removedFromRelation || removedToRelation; + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/TenantData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/TenantData.java new file mode 100644 index 0000000000..6822856edf --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/TenantData.java @@ -0,0 +1,34 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.fields.TenantFields; + +import java.util.UUID; + +public class TenantData extends BaseEntityData { + + public TenantData(UUID entityId) { + super(entityId); + } + + @Override + public EntityType getEntityType() { + return EntityType.TENANT; + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/AbstractDataPoint.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/AbstractDataPoint.java new file mode 100644 index 0000000000..fd2d099281 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/AbstractDataPoint.java @@ -0,0 +1,57 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data.dp; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.thingsboard.server.common.data.edqs.DataPoint; + +@RequiredArgsConstructor +public abstract class AbstractDataPoint implements DataPoint { + + @Getter + private final long ts; + + @Override + public String getStr() { + throw new RuntimeException(NOT_SUPPORTED); + } + + @Override + public long getLong() { + throw new RuntimeException(NOT_SUPPORTED); + } + + @Override + public double getDouble() { + throw new RuntimeException(NOT_SUPPORTED); + } + + @Override + public boolean getBool() { + throw new RuntimeException(NOT_SUPPORTED); + } + + @Override + public String getJson() { + throw new RuntimeException(NOT_SUPPORTED); + } + + public String toString() { + return valueToString(); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/BoolDataPoint.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/BoolDataPoint.java new file mode 100644 index 0000000000..83d91d8f75 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/BoolDataPoint.java @@ -0,0 +1,46 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data.dp; + +import lombok.Getter; +import org.thingsboard.server.common.data.kv.DataType; + +public class BoolDataPoint extends AbstractDataPoint { + + @Getter + private final boolean value; + + public BoolDataPoint(long ts, boolean value) { + super(ts); + this.value = value; + } + + @Override + public DataType getType() { + return DataType.BOOLEAN; + } + + @Override + public boolean getBool() { + return value; + } + + @Override + public String valueToString() { + return Boolean.toString(value); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/CompressedJsonDataPoint.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/CompressedJsonDataPoint.java new file mode 100644 index 0000000000..bce9d86875 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/CompressedJsonDataPoint.java @@ -0,0 +1,31 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data.dp; + +import org.thingsboard.server.common.data.kv.DataType; + +public class CompressedJsonDataPoint extends CompressedStringDataPoint { + + public CompressedJsonDataPoint(long ts, byte[] compressedValue) { + super(ts, compressedValue); + } + + @Override + public DataType getType() { + return DataType.JSON; + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/CompressedStringDataPoint.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/CompressedStringDataPoint.java new file mode 100644 index 0000000000..634b63e012 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/CompressedStringDataPoint.java @@ -0,0 +1,52 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data.dp; + +import lombok.Getter; +import lombok.SneakyThrows; +import org.thingsboard.server.common.data.kv.DataType; +import org.thingsboard.server.edqs.util.TbBytePool; +import org.xerial.snappy.Snappy; + +public class CompressedStringDataPoint extends AbstractDataPoint { + + public static final int MIN_STR_SIZE_TO_COMPRESS = 512; + @Getter + private final byte[] compressedValue; + + @SneakyThrows + public CompressedStringDataPoint(long ts, byte[] compressedValue) { + super(ts); + this.compressedValue = TbBytePool.intern(compressedValue); + } + + @Override + public DataType getType() { + return DataType.STRING; + } + + @SneakyThrows + @Override + public String getStr() { + return Snappy.uncompressString(compressedValue); + } + + @Override + public String valueToString() { + return getStr(); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/DoubleDataPoint.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/DoubleDataPoint.java new file mode 100644 index 0000000000..21b355bc46 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/DoubleDataPoint.java @@ -0,0 +1,46 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data.dp; + +import lombok.Getter; +import org.thingsboard.server.common.data.kv.DataType; + +public class DoubleDataPoint extends AbstractDataPoint { + + @Getter + private final double value; + + public DoubleDataPoint(long ts, double value) { + super(ts); + this.value = value; + } + + @Override + public DataType getType() { + return DataType.DOUBLE; + } + + @Override + public double getDouble() { + return value; + } + + @Override + public String valueToString() { + return Double.toString(value); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/JsonDataPoint.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/JsonDataPoint.java new file mode 100644 index 0000000000..3a8d570f43 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/JsonDataPoint.java @@ -0,0 +1,47 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data.dp; + +import lombok.Getter; +import org.thingsboard.server.common.data.kv.DataType; +import org.thingsboard.server.edqs.util.TbStringPool; + +public class JsonDataPoint extends AbstractDataPoint { + + @Getter + private final String value; + + public JsonDataPoint(long ts, String value) { + super(ts); + this.value = TbStringPool.intern(value); + } + + @Override + public DataType getType() { + return DataType.JSON; + } + + @Override + public String getJson() { + return value; + } + + @Override + public String valueToString() { + return value; + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/LongDataPoint.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/LongDataPoint.java new file mode 100644 index 0000000000..7fbe90e814 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/LongDataPoint.java @@ -0,0 +1,50 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data.dp; + +import lombok.Getter; +import org.thingsboard.server.common.data.kv.DataType; + +public class LongDataPoint extends AbstractDataPoint { + + @Getter + private final long value; + + public LongDataPoint(long ts, long value) { + super(ts); + this.value = value; + } + + @Override + public DataType getType() { + return DataType.LONG; + } + + @Override + public long getLong() { + return value; + } + + @Override + public double getDouble() { + return value; + } + + @Override + public String valueToString() { + return Long.toString(value); + } +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java new file mode 100644 index 0000000000..54156500fe --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java @@ -0,0 +1,51 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.data.dp; + +import lombok.Getter; +import org.thingsboard.server.common.data.kv.DataType; +import org.thingsboard.server.edqs.util.TbStringPool; + +public class StringDataPoint extends AbstractDataPoint { + + @Getter + private final String value; + + public StringDataPoint(long ts, String value) { + this(ts, value, true); + } + + public StringDataPoint(long ts, String value, boolean deduplicate) { + super(ts); + this.value = deduplicate ? TbStringPool.intern(value) : value; + } + + @Override + public DataType getType() { + return DataType.STRING; + } + + @Override + public String getStr() { + return value; + } + + @Override + public String valueToString() { + return value; + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/processor/EdqsProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/processor/EdqsProcessor.java new file mode 100644 index 0000000000..7ddc9147df --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/processor/EdqsProcessor.java @@ -0,0 +1,299 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.processor; + +import com.google.common.collect.Sets; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.ExceptionUtil; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.edqs.EdqsEvent; +import org.thingsboard.server.common.data.edqs.EdqsEventType; +import org.thingsboard.server.common.data.edqs.EdqsObject; +import org.thingsboard.server.common.data.edqs.query.EdqsRequest; +import org.thingsboard.server.common.data.edqs.query.EdqsResponse; +import org.thingsboard.server.common.data.edqs.query.QueryResult; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.util.CollectionsUtil; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.edqs.repo.EdqsRepository; +import org.thingsboard.server.edqs.state.EdqsPartitionService; +import org.thingsboard.server.edqs.state.EdqsStateService; +import org.thingsboard.server.edqs.util.EdqsConverter; +import org.thingsboard.server.edqs.util.VersionsStore; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.EdqsEventMsg; +import org.thingsboard.server.gen.transport.TransportProtos.FromEdqsMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; +import org.thingsboard.server.queue.TbQueueHandler; +import org.thingsboard.server.queue.TbQueueResponseTemplate; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; +import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; +import org.thingsboard.server.queue.edqs.EdqsComponent; +import org.thingsboard.server.queue.edqs.EdqsConfig; +import org.thingsboard.server.queue.edqs.EdqsConfig.EdqsPartitioningStrategy; +import org.thingsboard.server.queue.edqs.EdqsQueue; +import org.thingsboard.server.queue.edqs.EdqsQueueFactory; +import org.thingsboard.server.queue.util.AfterStartUp; + +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import static org.thingsboard.server.common.msg.queue.TopicPartitionInfo.withTopic; + +@EdqsComponent +@Service +@RequiredArgsConstructor +@Slf4j +public class EdqsProcessor implements TbQueueHandler, TbProtoQueueMsg> { + + private final EdqsQueueFactory queueFactory; + private final EdqsConverter converter; + private final EdqsRepository repository; + private final EdqsConfig config; + private final EdqsPartitionService partitionService; + private final ConfigurableApplicationContext applicationContext; + private final EdqsStateService stateService; + + private PartitionedQueueConsumerManager> eventConsumer; + private TbQueueResponseTemplate, TbProtoQueueMsg> responseTemplate; + + private ExecutorService consumersExecutor; + private ExecutorService taskExecutor; + private ScheduledExecutorService scheduler; + private ListeningExecutorService requestExecutor; + + private final VersionsStore versionsStore = new VersionsStore(); + + private final AtomicInteger counter = new AtomicInteger(); + + @Getter + private Consumer errorHandler; + + @PostConstruct + private void init() { + consumersExecutor = Executors.newCachedThreadPool(ThingsBoardThreadFactory.forName("edqs-consumer")); + taskExecutor = ThingsBoardExecutors.newWorkStealingPool(4, "edqs-consumer-task-executor"); + scheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edqs-scheduler"); + requestExecutor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(12, "edqs-requests")); + errorHandler = error -> { + if (error instanceof OutOfMemoryError) { + log.error("OOM detected, shutting down"); + repository.clear(); + Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("edqs-shutdown")) + .execute(applicationContext::close); + } + }; + + eventConsumer = PartitionedQueueConsumerManager.>create() + .queueKey(new QueueKey(ServiceType.EDQS, EdqsQueue.EVENTS.getTopic())) + .topic(EdqsQueue.EVENTS.getTopic()) + .pollInterval(config.getPollInterval()) + .msgPackProcessor((msgs, consumer, config) -> { + for (TbProtoQueueMsg queueMsg : msgs) { + if (consumer.isStopped()) { + return; + } + try { + ToEdqsMsg msg = queueMsg.getValue(); + process(msg, EdqsQueue.EVENTS); + } catch (Exception t) { + log.error("Failed to process message: {}", queueMsg, t); + } + } + consumer.commit(); + }) + .consumerCreator((config, partitionId) -> queueFactory.createEdqsMsgConsumer(EdqsQueue.EVENTS)) + .queueAdmin(queueFactory.getEdqsQueueAdmin()) + .consumerExecutor(consumersExecutor) + .taskExecutor(taskExecutor) + .scheduler(scheduler) + .uncaughtErrorHandler(errorHandler) + .build(); + stateService.init(eventConsumer); + + responseTemplate = queueFactory.createEdqsResponseTemplate(); + } + + @AfterStartUp(order = 1) + public void start() { + responseTemplate.launch(this); + } + + @EventListener + public void onPartitionsChange(PartitionChangeEvent event) { + if (event.getServiceType() != ServiceType.EDQS) { + return; + } + try { + Set newPartitions = event.getNewPartitions().get(new QueueKey(ServiceType.EDQS)); + Set partitions = newPartitions.stream() + .map(tpi -> tpi.withUseInternalPartition(true)) + .collect(Collectors.toSet()); + + stateService.process(withTopic(partitions, EdqsQueue.STATE.getTopic())); + // eventsConsumer's partitions are updated by stateService + responseTemplate.subscribe(withTopic(partitions, config.getRequestsTopic())); // FIXME: we subscribe to partitions before we are ready. implement consumer-per-partition version for request template + + Set oldPartitions = event.getOldPartitions().get(new QueueKey(ServiceType.EDQS)); + if (CollectionsUtil.isNotEmpty(oldPartitions)) { + Set removedPartitions = Sets.difference(oldPartitions, newPartitions).stream() + .map(tpi -> tpi.getPartition().orElse(-1)).collect(Collectors.toSet()); + if (config.getPartitioningStrategy() != EdqsPartitioningStrategy.TENANT && !removedPartitions.isEmpty()) { + log.warn("Partitions {} were removed but shouldn't be (due to NONE partitioning strategy)", removedPartitions); + } + repository.clearIf(tenantId -> { + Integer partition = partitionService.resolvePartition(tenantId); + return partition != null && removedPartitions.contains(partition); + }); + } + } catch (Throwable t) { + log.error("Failed to handle partition change event {}", event, t); + } + } + + @Override + public ListenableFuture> handle(TbProtoQueueMsg queueMsg) { + ToEdqsMsg toEdqsMsg = queueMsg.getValue(); + return requestExecutor.submit(() -> { + EdqsRequest request; + TenantId tenantId; + CustomerId customerId; + try { + request = Objects.requireNonNull(JacksonUtil.fromString(toEdqsMsg.getRequestMsg().getValue(), EdqsRequest.class)); + tenantId = getTenantId(toEdqsMsg); + customerId = getCustomerId(toEdqsMsg); + } catch (Exception e) { + log.error("Failed to parse request msg: {}", toEdqsMsg, e); + throw e; + } + + EdqsResponse response = processRequest(tenantId, customerId, request); + return new TbProtoQueueMsg<>(queueMsg.getKey(), FromEdqsMsg.newBuilder() + .setResponseMsg(TransportProtos.EdqsResponseMsg.newBuilder() + .setValue(JacksonUtil.toString(response)) + .build()) + .build(), queueMsg.getHeaders()); + }); + } + + private EdqsResponse processRequest(TenantId tenantId, CustomerId customerId, EdqsRequest request) { + EdqsResponse response = new EdqsResponse(); + try { + if (request.getEntityDataQuery() != null) { + PageData result = repository.findEntityDataByQuery(tenantId, customerId, + request.getEntityDataQuery(), false); + response.setEntityDataQueryResult(result.mapData(QueryResult::toOldEntityData)); + } else if (request.getEntityCountQuery() != null) { + long result = repository.countEntitiesByQuery(tenantId, customerId, request.getEntityCountQuery(), tenantId.isSysTenantId()); + response.setEntityCountQueryResult(result); + } + log.trace("[{}] Request: {}, response: {}", tenantId, request, response); + } catch (Throwable e) { + log.error("[{}] Failed to process request: {}", tenantId, request, e); + response.setError(ExceptionUtil.getMessage(e)); + } + return response; + } + + public void process(ToEdqsMsg edqsMsg, EdqsQueue queue) { + log.trace("Processing message: {}", edqsMsg); + if (edqsMsg.hasEventMsg()) { + EdqsEventMsg eventMsg = edqsMsg.getEventMsg(); + TenantId tenantId = getTenantId(edqsMsg); + ObjectType objectType = ObjectType.valueOf(eventMsg.getObjectType()); + EdqsEventType eventType = EdqsEventType.valueOf(eventMsg.getEventType()); + String key = eventMsg.getKey(); + Long version = eventMsg.hasVersion() ? eventMsg.getVersion() : null; + + if (version != null) { + if (!versionsStore.isNew(key, version)) { + return; + } + } else if (!ObjectType.unversionedTypes.contains(objectType)) { + log.warn("[{}] {} {} doesn't have version", tenantId, objectType, key); + } + if (queue != EdqsQueue.STATE) { + stateService.save(tenantId, objectType, key, eventType, edqsMsg); + } + + EdqsObject object = converter.deserialize(objectType, eventMsg.getData().toByteArray()); + log.debug("[{}] Processing event [{}] [{}] [{}] [{}]", tenantId, objectType, eventType, key, version); + int count = counter.incrementAndGet(); + if (count % 100000 == 0) { + log.info("Processed {} events", count); + } + + EdqsEvent event = EdqsEvent.builder() + .tenantId(tenantId) + .objectType(objectType) + .eventType(eventType) + .object(object) + .build(); + repository.processEvent(event); + } + } + + private TenantId getTenantId(ToEdqsMsg edqsMsg) { + return TenantId.fromUUID(new UUID(edqsMsg.getTenantIdMSB(), edqsMsg.getTenantIdLSB())); + } + + private CustomerId getCustomerId(ToEdqsMsg edqsMsg) { + if (edqsMsg.getCustomerIdMSB() != 0 && edqsMsg.getCustomerIdLSB() != 0) { + return new CustomerId(new UUID(edqsMsg.getCustomerIdMSB(), edqsMsg.getCustomerIdLSB())); + } else { + return null; + } + } + + @PreDestroy + public void destroy() throws InterruptedException { + eventConsumer.stop(); + eventConsumer.awaitStop(); + responseTemplate.stop(); + stateService.stop(); + + consumersExecutor.shutdownNow(); + taskExecutor.shutdownNow(); + scheduler.shutdownNow(); + requestExecutor.shutdownNow(); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/processor/EdqsProducer.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/processor/EdqsProducer.java new file mode 100644 index 0000000000..be1f0481be --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/processor/EdqsProducer.java @@ -0,0 +1,92 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.processor; + +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.common.errors.RecordTooLargeException; +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.edqs.state.EdqsPartitionService; +import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueMsgMetadata; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.TopicService; +import org.thingsboard.server.queue.edqs.EdqsQueue; +import org.thingsboard.server.queue.kafka.TbKafkaProducerTemplate; + +@Slf4j +public class EdqsProducer { + + private final EdqsQueue queue; + private final EdqsPartitionService partitionService; + private final TopicService topicService; + + private final TbQueueProducer> producer; + + @Builder + public EdqsProducer(EdqsQueue queue, + EdqsPartitionService partitionService, + TopicService topicService, + TbQueueProducer> producer) { + this.queue = queue; + this.partitionService = partitionService; + this.topicService = topicService; + this.producer = producer; + } + + public void send(TenantId tenantId, ObjectType type, String key, ToEdqsMsg msg) { + String topic = topicService.buildTopicName(queue.getTopic()); + TbQueueCallback callback = new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + log.trace("[{}][{}][{}] Published msg to {}: {}", tenantId, type, key, topic, msg); + } + + @Override + public void onFailure(Throwable t) { + if (t instanceof RecordTooLargeException) { + if (!log.isDebugEnabled()) { + log.warn("[{}][{}][{}] Failed to publish msg to {}", tenantId, type, key, topic, t); // not logging the whole message + return; + } + } + log.warn("[{}][{}][{}] Failed to publish msg to {}: {}", tenantId, type, key, topic, msg, t); + } + }; + if (producer instanceof TbKafkaProducerTemplate> kafkaProducer) { + TopicPartitionInfo tpi = TopicPartitionInfo.builder() + .topic(topic) + .partition(partitionService.resolvePartition(tenantId)) + .useInternalPartition(true) + .build(); + kafkaProducer.send(tpi, key, new TbProtoQueueMsg<>(null, msg), callback); // specifying custom key for compaction + } else { + TopicPartitionInfo tpi = TopicPartitionInfo.builder() + .topic(topic) + .build(); + producer.send(tpi, new TbProtoQueueMsg<>(null, msg), callback); + } + } + + public void stop() { + producer.stop(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/QueueEvent.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/DataKey.java similarity index 77% rename from application/src/main/java/org/thingsboard/server/service/queue/ruleengine/QueueEvent.java rename to common/edqs/src/main/java/org/thingsboard/server/edqs/query/DataKey.java index 4911bb9296..6d9a672e2c 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/QueueEvent.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/DataKey.java @@ -13,12 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.queue.ruleengine; +package org.thingsboard.server.edqs.query; -import java.io.Serializable; +import org.thingsboard.server.common.data.query.EntityKeyType; -public enum QueueEvent implements Serializable { - - PARTITION_CHANGE, CONFIG_UPDATE, DELETE +public record DataKey(EntityKeyType type, String key, Integer keyId) { } diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java new file mode 100644 index 0000000000..9c73c3ed9e --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java @@ -0,0 +1,30 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query; + +import lombok.Builder; +import org.thingsboard.server.common.data.query.EntityFilter; + +import java.util.List; + +public class EdqsCountQuery extends EdqsQuery { + + @Builder + EdqsCountQuery(EntityFilter entityFilter, boolean hasKeyFilters, List keyFilters) { + super(entityFilter, hasKeyFilters, keyFilters); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java new file mode 100644 index 0000000000..8e118c6e58 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java @@ -0,0 +1,59 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query; + +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityFilter; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.util.CollectionsUtil; + +import java.util.List; + +@EqualsAndHashCode(callSuper = true) +@Getter +public class EdqsDataQuery extends EdqsQuery { + + private final int pageSize; + private final int page; + private final boolean hasTextSearch; + private final String textSearch; + private final boolean defaultSort; + private final DataKey sortKey; + private final EntityDataSortOrder.Direction sortDirection; + private final List entityFields; + private final List latestValues; + + @Builder + public EdqsDataQuery(EntityFilter entityFilter, List keyFilters, + int pageSize, int page, String textSearch, DataKey sortKey, EntityDataSortOrder.Direction sortDirection, + List entityFields, List latestValues) { + super(entityFilter, CollectionsUtil.isNotEmpty(keyFilters), keyFilters); + this.pageSize = pageSize; + this.page = page; + this.hasTextSearch = StringUtils.isNotBlank(textSearch); + this.textSearch = textSearch; + this.defaultSort = EntityKeyType.ENTITY_FIELD.equals(sortKey.type()) && "createdTime".equals(sortKey.key()) && EntityDataSortOrder.Direction.DESC.equals(sortDirection); + this.sortKey = sortKey; + this.sortDirection = sortDirection; + this.entityFields = entityFields; + this.latestValues = latestValues; + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsFilter.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsFilter.java new file mode 100644 index 0000000000..67018ebbb8 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsFilter.java @@ -0,0 +1,23 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query; + +import org.thingsboard.server.common.data.query.EntityKeyValueType; +import org.thingsboard.server.common.data.query.KeyFilterPredicate; + +public record EdqsFilter(DataKey key, EntityKeyValueType valueType, KeyFilterPredicate predicate) { + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java new file mode 100644 index 0000000000..aa20c7c306 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java @@ -0,0 +1,30 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query; + +import lombok.Data; +import org.thingsboard.server.common.data.query.EntityFilter; + +import java.util.List; + +@Data +public abstract class EdqsQuery { + + private final EntityFilter entityFilter; + private final boolean hasKeyFilters; + private final List keyFilters; + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/SortableEntityData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/SortableEntityData.java new file mode 100644 index 0000000000..026c470ce6 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/SortableEntityData.java @@ -0,0 +1,38 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query; + +import lombok.Data; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.edqs.data.EntityData; + +import java.util.UUID; + +@Data +public class SortableEntityData { + + private final EntityData entityData; + private String sortValue; + + public UUID getId(){ + return entityData.getId(); + } + + public EntityId getEntityId() { + return EntityIdFactory.getByTypeAndUuid(entityData.getEntityType(), entityData.getId()); + } +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractEntityProfileNameQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractEntityProfileNameQueryProcessor.java new file mode 100644 index 0000000000..b78e49879e --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractEntityProfileNameQueryProcessor.java @@ -0,0 +1,52 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.EntityFilter; +import org.thingsboard.server.edqs.data.EntityData; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; +import org.thingsboard.server.edqs.util.RepositoryUtils; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +public abstract class AbstractEntityProfileNameQueryProcessor extends AbstractSimpleQueryProcessor { + + private final Set entityProfileNames; + private final Pattern pattern; + + public AbstractEntityProfileNameQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query, T filter, EntityType entityType) { + super(repo, ctx, query, filter, entityType); + entityProfileNames = new HashSet<>(getProfileNames(this.filter)); + pattern = RepositoryUtils.toSqlLikePattern(getEntityNameFilter(filter)); + } + + protected abstract String getEntityNameFilter(T filter); + + protected abstract List getProfileNames(T filter); + + @Override + protected boolean matches(EntityData ed) { + return super.matches(ed) && entityProfileNames.contains(ed.getFields().getType()) + && (pattern == null || pattern.matcher(ed.getFields().getName()).matches()); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractEntityProfileQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractEntityProfileQueryProcessor.java new file mode 100644 index 0000000000..301ead7c63 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractEntityProfileQueryProcessor.java @@ -0,0 +1,62 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.EntityFilter; +import org.thingsboard.server.edqs.data.EntityData; +import org.thingsboard.server.edqs.data.ProfileAwareData; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; +import org.thingsboard.server.edqs.util.RepositoryUtils; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +public abstract class AbstractEntityProfileQueryProcessor extends AbstractSimpleQueryProcessor { + + private final Set entityProfileIds = new HashSet<>(); + private final Pattern pattern; + + public AbstractEntityProfileQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query, T filter, EntityType entityType) { + super(repo, ctx, query, filter, entityType); + var profileNamesSet = new HashSet<>(getProfileNames(this.filter)); + for (EntityData dp : repo.getEntitySet(getProfileEntityType())) { + if (profileNamesSet.contains(dp.getFields().getName())) { + entityProfileIds.add(dp.getId()); + } + } + pattern = RepositoryUtils.toSqlLikePattern(getEntityNameFilter(filter)); + } + + protected abstract String getEntityNameFilter(T filter); + + protected abstract List getProfileNames(T filter); + + protected abstract EntityType getProfileEntityType(); + + @Override + protected boolean matches(EntityData ed) { + ProfileAwareData profileAwareData = (ProfileAwareData) ed; + return super.matches(ed) && entityProfileIds.contains(profileAwareData.getFields().getProfileId()) + && (pattern == null || pattern.matcher(profileAwareData.getFields().getName()).matches()); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractEntitySearchQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractEntitySearchQueryProcessor.java new file mode 100644 index 0000000000..b34cd8a459 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractEntitySearchQueryProcessor.java @@ -0,0 +1,66 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.EntitySearchQueryFilter; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.edqs.data.EntityData; +import org.thingsboard.server.edqs.data.RelationInfo; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; + +import java.util.Set; +import java.util.UUID; + +public abstract class AbstractEntitySearchQueryProcessor extends AbstractRelationQueryProcessor { + + + public AbstractEntitySearchQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query, T filter) { + super(repo, ctx, query, filter); + } + + @Override + public Set getRootEntities() { + return Set.of(filter.getRootEntity().getId()); + } + + @Override + public EntitySearchDirection getDirection() { + return filter.getDirection(); + } + + @Override + public int getMaxLevel() { + return filter.getMaxLevel(); + } + + @Override + public boolean isFetchLastLevelOnly() { + return filter.isFetchLastLevelOnly(); + } + + public abstract EntityType getEntityType(); + + @Override + protected boolean check(RelationInfo relationInfo) { + EntityData target = relationInfo.getTarget(); + return (filter.getRelationType() == null || relationInfo.getType().equals(filter.getRelationType())) && + getEntityType().equals(target.getEntityType()) && super.matches(target); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractQueryProcessor.java new file mode 100644 index 0000000000..e4cded3e3e --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractQueryProcessor.java @@ -0,0 +1,74 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.EntityFilter; +import org.thingsboard.server.edqs.data.EntityData; +import org.thingsboard.server.edqs.query.DataKey; +import org.thingsboard.server.edqs.query.EdqsDataQuery; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.query.SortableEntityData; +import org.thingsboard.server.edqs.repo.TenantRepo; + +import java.util.Collection; +import java.util.UUID; +import java.util.function.Consumer; + +import static org.thingsboard.server.edqs.util.RepositoryUtils.checkFilters; +import static org.thingsboard.server.edqs.util.RepositoryUtils.getSortValue; + +public abstract class AbstractQueryProcessor implements EntityQueryProcessor { + + protected final TenantRepo repository; + protected final QueryContext ctx; + protected final EdqsQuery query; + protected final DataKey sortKey; + protected final T filter; + + public AbstractQueryProcessor(TenantRepo repository, QueryContext ctx, EdqsQuery query, T filter) { + this.repository = repository; + this.ctx = ctx; + this.query = query; + this.sortKey = query instanceof EdqsDataQuery dataQuery ? dataQuery.getSortKey() : null; + this.filter = filter; + } + + protected SortableEntityData toSortData(EntityData ed) { + SortableEntityData sortData = new SortableEntityData(ed); + sortData.setSortValue(getSortValue(ed, sortKey)); + return sortData; + } + + protected void process(Collection> entities, Consumer> processor) { + for (EntityData ed : entities) { + if (matches(ed)) { + processor.accept(ed); + } + } + } + + protected static boolean checkCustomerId(UUID customerId, EntityData ed) { + return customerId.equals(ed.getCustomerId()) || (ed.getEntityType() == EntityType.DASHBOARD && + ed.getFields().getAssignedCustomerIds().contains(customerId)); + } + + protected boolean matches(EntityData ed) { + return checkFilters(query, ed); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractRelationQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractRelationQueryProcessor.java new file mode 100644 index 0000000000..8ee7338a4f --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractRelationQueryProcessor.java @@ -0,0 +1,170 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import lombok.RequiredArgsConstructor; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.EntityFilter; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.edqs.data.EntityData; +import org.thingsboard.server.edqs.data.RelationInfo; +import org.thingsboard.server.edqs.data.RelationsRepo; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.query.SortableEntityData; +import org.thingsboard.server.edqs.repo.TenantRepo; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import java.util.Set; +import java.util.UUID; + + +public abstract class AbstractRelationQueryProcessor extends AbstractQueryProcessor { + + public static final int MAXIMUM_QUERY_LEVEL = 100; + + public AbstractRelationQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query, T filter) { + super(repo, ctx, query, filter); + } + + protected abstract Set getRootEntities(); + + protected abstract EntitySearchDirection getDirection(); + + protected abstract int getMaxLevel(); + + protected abstract boolean isFetchLastLevelOnly(); + + protected boolean isMultiRoot() { + return false; + } + + @Override + public List processQuery() { + var relations = repository.getRelations(RelationTypeGroup.COMMON); + var entities = getEntitiesSet(relations); + if (ctx.isTenantUser()) { + return processTenantQuery(entities); + } else { + return processCustomerQuery(entities); + } + } + + @Override + public long count() { + var relations = repository.getRelations(RelationTypeGroup.COMMON); + var entities = getEntitiesSet(relations); + long result = 0; + + if (ctx.isTenantUser()) { + return entities.size(); + } else { + var customerId = ctx.getCustomerId().getId(); + for (EntityData ed : entities) { + if (checkCustomerId(customerId, ed)) { + result++; + } + } + return result; + } + } + + private List processTenantQuery(Set> entities) { + return entities.stream() + .map(this::toSortData) + .toList(); + } + + private List processCustomerQuery(Set> entities) { + var customerId = ctx.getCustomerId().getId(); + List result = new ArrayList<>(); + for (EntityData ed : entities) { + if (checkCustomerId(customerId, ed)) { + result.add(toSortData(ed)); + } + } + return result; + } + + private Set> getEntitiesSet(RelationsRepo relations) { + Set> result = new HashSet<>(); + Set processed = new HashSet<>(); + Queue tasks = new LinkedList<>(); + int maxLvl = getMaxLevel() == 0 ? MAXIMUM_QUERY_LEVEL : Math.max(1, getMaxLevel()); + for (UUID uuid : getRootEntities()) { + tasks.add(new RelationSearchTask(uuid, 0)); + } + while (!tasks.isEmpty()) { + RelationSearchTask task = tasks.poll(); + if (processed.add(task.entityId)) { + var entityLvl = task.lvl + 1; + Set entities = EntitySearchDirection.FROM.equals(getDirection()) ? relations.getFrom(task.entityId) : relations.getTo(task.entityId); + if (isFetchLastLevelOnly() && entities.isEmpty() && task.previous != null && check(task.previous)) { + result.add(task.previous.getTarget()); + } + for (RelationInfo relationInfo : entities) { + var entity = relationInfo.getTarget(); + if (entity.isEmpty()) { + continue; + } + var entityId = entity.getId(); + if (isFetchLastLevelOnly()) { + if (entityLvl < maxLvl) { + tasks.add(new RelationSearchTask(entityId, entityLvl, relationInfo)); + } else if (entityLvl == maxLvl) { + if (check(relationInfo)) { + if (isMultiRoot()) { + ctx.getRelatedParentIdMap().put(entity.getId(), task.entityId); + } + result.add(entity); + } + } + } else { + if (check(relationInfo)) { + if (isMultiRoot()) { + ctx.getRelatedParentIdMap().put(entity.getId(), task.entityId); + } + result.add(entity); + } + if (entityLvl < maxLvl) { + tasks.add(new RelationSearchTask(entityId, entityLvl)); + } + } + } + } + } + return result; + } + + protected abstract boolean check(RelationInfo relationInfo); + + @RequiredArgsConstructor + private static class RelationSearchTask { + private final UUID entityId; + private final int lvl; + private final RelationInfo previous; + + public RelationSearchTask(UUID entityId, int lvl) { + this(entityId, lvl, null); + } + + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractSimpleQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractSimpleQueryProcessor.java new file mode 100644 index 0000000000..aab1e83879 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractSimpleQueryProcessor.java @@ -0,0 +1,56 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.EntityFilter; +import org.thingsboard.server.edqs.data.CustomerData; +import org.thingsboard.server.edqs.data.EntityData; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; + +import java.util.UUID; +import java.util.function.Consumer; + +public abstract class AbstractSimpleQueryProcessor extends AbstractSingleEntityTypeQueryProcessor { + + private final EntityType entityType; + + public AbstractSimpleQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query, T filter, EntityType entityType) { + super(repo, ctx, query, filter); + this.entityType = entityType; + } + + @Override + protected void processCustomerQuery(UUID customerId, Consumer> processor) { + var customerData = (CustomerData) repository.getEntityMap(EntityType.CUSTOMER).get(customerId); + if (customerData != null) { + process(customerData.getEntities(entityType), processor); + } + } + + @Override + protected void processAll(Consumer> processor) { + process(repository.getEntitySet(entityType), processor); + } + + @Override + protected int getProbableResultSize() { + return 1024; + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractSingleEntityTypeQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractSingleEntityTypeQueryProcessor.java new file mode 100644 index 0000000000..1723ee5f5b --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AbstractSingleEntityTypeQueryProcessor.java @@ -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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.EntityFilter; +import org.thingsboard.server.edqs.data.EntityData; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.query.SortableEntityData; +import org.thingsboard.server.edqs.repo.TenantRepo; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + +public abstract class AbstractSingleEntityTypeQueryProcessor extends AbstractQueryProcessor { + + public AbstractSingleEntityTypeQueryProcessor(TenantRepo repository, QueryContext ctx, EdqsQuery query, T filter) { + super(repository, ctx, query, filter); + } + + @Override + public List processQuery() { + if (ctx.isTenantUser()) { + return processTenantQuery(); + } else { + return processCustomerQuery(ctx.getCustomerId().getId()); + } + } + + @Override + public long count() { + AtomicLong result = new AtomicLong(); + Consumer> counter = ed -> result.incrementAndGet(); + + if (ctx.isIgnorePermissionCheck()) { + processAll(counter); + } else if (ctx.isTenantUser()) { + processAll(counter); + } else { + processCustomerQuery(ctx.getCustomerId().getId(), counter); + } + return result.get(); + } + + protected List processTenantQuery() { + List result = new ArrayList<>(getProbableResultSize()); + processAll(ed -> { + result.add(toSortData(ed)); + }); + return result; + } + + protected List processCustomerQuery(UUID customerId) { + List result = new ArrayList<>(getProbableResultSize()); + processCustomerQuery(customerId, ed -> { + result.add(toSortData(ed)); + }); + return result; + } + + protected abstract void processCustomerQuery(UUID customerId, Consumer> processor); + + protected abstract void processAll(Consumer> processor); + + protected abstract int getProbableResultSize(); + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/ApiUsageStateQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/ApiUsageStateQueryProcessor.java new file mode 100644 index 0000000000..44370292e4 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/ApiUsageStateQueryProcessor.java @@ -0,0 +1,60 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.fields.ApiUsageStateFields; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.ApiUsageStateFilter; +import org.thingsboard.server.edqs.data.CustomerData; +import org.thingsboard.server.edqs.data.EntityData; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; + +import java.util.UUID; +import java.util.function.Consumer; + +public class ApiUsageStateQueryProcessor extends AbstractSingleEntityTypeQueryProcessor { + + public ApiUsageStateQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query) { + super(repo, ctx, query, (ApiUsageStateFilter) query.getEntityFilter()); + } + + @Override + protected void processCustomerQuery(UUID customerId, Consumer> processor) { + CustomerData customerData = (CustomerData) repository.getEntityMap(EntityType.CUSTOMER).get(customerId); + if (customerData != null) { + process(customerData.getEntities(EntityType.API_USAGE_STATE), processor); + } + } + + @Override + protected void processAll(Consumer> processor) { + process(repository.getEntitySet(EntityType.API_USAGE_STATE), processor); + } + + @Override + protected boolean matches(EntityData ed) { + ApiUsageStateFields entityFields = (ApiUsageStateFields) ed.getFields(); + return super.matches(ed) && (filter.getCustomerId() == null || filter.getCustomerId().equals(entityFields.getEntityId())); + } + + @Override + protected int getProbableResultSize() { + return 1; + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AssetSearchQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AssetSearchQueryProcessor.java new file mode 100644 index 0000000000..2eff8f6d15 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AssetSearchQueryProcessor.java @@ -0,0 +1,59 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.AssetSearchQueryFilter; +import org.thingsboard.server.common.data.util.CollectionsUtil; +import org.thingsboard.server.edqs.data.EntityData; +import org.thingsboard.server.edqs.data.ProfileAwareData; +import org.thingsboard.server.edqs.data.RelationInfo; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +public class AssetSearchQueryProcessor extends AbstractEntitySearchQueryProcessor { + + private final Set entityProfileIds = new HashSet<>(); + + public AssetSearchQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query) { + super(repo, ctx, query, (AssetSearchQueryFilter) query.getEntityFilter()); + if (CollectionsUtil.isNotEmpty(filter.getAssetTypes())) { + var profileNamesSet = new HashSet<>(this.filter.getAssetTypes()); + for (EntityData dp : repo.getEntitySet(EntityType.ASSET_PROFILE)) { + if (profileNamesSet.contains(dp.getFields().getName())) { + entityProfileIds.add(dp.getId()); + } + } + } + } + + @Override + public EntityType getEntityType() { + return EntityType.ASSET; + } + + @Override + protected boolean check(RelationInfo relationInfo) { + return super.check(relationInfo) && + (entityProfileIds.isEmpty() || entityProfileIds.contains(((ProfileAwareData) relationInfo.getTarget()).getFields().getProfileId())); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AssetTypeQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AssetTypeQueryProcessor.java new file mode 100644 index 0000000000..cee5edfa9b --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/AssetTypeQueryProcessor.java @@ -0,0 +1,47 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.AssetTypeFilter; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; + +import java.util.List; + +public class AssetTypeQueryProcessor extends AbstractEntityProfileQueryProcessor { + + public AssetTypeQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query) { + super(repo, ctx, query, (AssetTypeFilter) query.getEntityFilter(), EntityType.ASSET); + } + + @Override + protected String getEntityNameFilter(AssetTypeFilter filter) { + return filter.getAssetNameFilter(); + } + + @Override + protected List getProfileNames(AssetTypeFilter filter) { + return filter.getAssetTypes(); + } + + @Override + protected EntityType getProfileEntityType() { + return EntityType.ASSET_PROFILE; + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/DeviceSearchQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/DeviceSearchQueryProcessor.java new file mode 100644 index 0000000000..3e53c0815f --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/DeviceSearchQueryProcessor.java @@ -0,0 +1,59 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.DeviceSearchQueryFilter; +import org.thingsboard.server.common.data.util.CollectionsUtil; +import org.thingsboard.server.edqs.data.EntityData; +import org.thingsboard.server.edqs.data.ProfileAwareData; +import org.thingsboard.server.edqs.data.RelationInfo; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +public class DeviceSearchQueryProcessor extends AbstractEntitySearchQueryProcessor { + + private final Set entityProfileIds = new HashSet<>(); + + public DeviceSearchQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query) { + super(repo, ctx, query, (DeviceSearchQueryFilter) query.getEntityFilter()); + if (CollectionsUtil.isNotEmpty(filter.getDeviceTypes())) { + var profileNamesSet = new HashSet<>(this.filter.getDeviceTypes()); + for (EntityData dp : repo.getEntitySet(EntityType.DEVICE_PROFILE)) { + if (profileNamesSet.contains(dp.getFields().getName())) { + entityProfileIds.add(dp.getId()); + } + } + } + } + + @Override + public EntityType getEntityType() { + return EntityType.DEVICE; + } + + @Override + protected boolean check(RelationInfo relationInfo) { + return super.check(relationInfo) && + (entityProfileIds.isEmpty() || entityProfileIds.contains(((ProfileAwareData) relationInfo.getTarget()).getFields().getProfileId())); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/DeviceTypeQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/DeviceTypeQueryProcessor.java new file mode 100644 index 0000000000..44eaf3e74a --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/DeviceTypeQueryProcessor.java @@ -0,0 +1,47 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.DeviceTypeFilter; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; + +import java.util.List; + +public class DeviceTypeQueryProcessor extends AbstractEntityProfileQueryProcessor { + + public DeviceTypeQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query) { + super(repo, ctx, query, (DeviceTypeFilter) query.getEntityFilter(), EntityType.DEVICE); + } + + @Override + protected String getEntityNameFilter(DeviceTypeFilter filter) { + return filter.getDeviceNameFilter(); + } + + @Override + protected List getProfileNames(DeviceTypeFilter filter) { + return filter.getDeviceTypes(); + } + + @Override + protected EntityType getProfileEntityType() { + return EntityType.DEVICE_PROFILE; + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EdgeTypeQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EdgeTypeQueryProcessor.java new file mode 100644 index 0000000000..965d3e09ca --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EdgeTypeQueryProcessor.java @@ -0,0 +1,42 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.EdgeTypeFilter; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; + +import java.util.List; + +public class EdgeTypeQueryProcessor extends AbstractEntityProfileNameQueryProcessor { + + public EdgeTypeQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query) { + super(repo, ctx, query, (EdgeTypeFilter) query.getEntityFilter(), EntityType.EDGE); + } + + @Override + protected String getEntityNameFilter(EdgeTypeFilter filter) { + return filter.getEdgeNameFilter(); + } + + @Override + protected List getProfileNames(EdgeTypeFilter filter) { + return filter.getEdgeTypes(); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EdgeTypeSearchQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EdgeTypeSearchQueryProcessor.java new file mode 100644 index 0000000000..e9e174505b --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EdgeTypeSearchQueryProcessor.java @@ -0,0 +1,44 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.EdgeSearchQueryFilter; +import org.thingsboard.server.edqs.data.EntityData; +import org.thingsboard.server.edqs.data.RelationInfo; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; + +public class EdgeTypeSearchQueryProcessor extends AbstractEntitySearchQueryProcessor { + + public EdgeTypeSearchQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query) { + super(repo, ctx, query, (EdgeSearchQueryFilter) query.getEntityFilter()); + } + + @Override + public EntityType getEntityType() { + return EntityType.EDGE; + } + + @Override + protected boolean check(RelationInfo relationInfo) { + EntityData ed = relationInfo.getTarget(); + return super.check(relationInfo) && + (filter.getEdgeTypes() == null || filter.getEdgeTypes().contains(ed.getFields().getType())); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityListQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityListQueryProcessor.java new file mode 100644 index 0000000000..3a1eebf1e9 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityListQueryProcessor.java @@ -0,0 +1,66 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.EntityListFilter; +import org.thingsboard.server.edqs.data.EntityData; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; + +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +public class EntityListQueryProcessor extends AbstractSingleEntityTypeQueryProcessor { + + private final EntityType entityType; + private final Set entityIds; + + public EntityListQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query) { + super(repo, ctx, query, (EntityListFilter) query.getEntityFilter()); + this.entityType = filter.getEntityType(); + this.entityIds = filter.getEntityList().stream().map(UUID::fromString).collect(Collectors.toSet()); + } + + @Override + protected void processCustomerQuery(UUID customerId, Consumer> processor) { + processAll(ed -> { + if (checkCustomerId(customerId, ed)) { + processor.accept(ed); + } + }); + } + + @Override + protected void processAll(Consumer> processor) { + var map = repository.getEntityMap(entityType); + for (UUID entityId : entityIds) { + EntityData ed = map.get(entityId); + if (matches(ed)) { + processor.accept(ed); + } + } + } + + @Override + protected int getProbableResultSize() { + return entityIds.size(); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityNameQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityNameQueryProcessor.java new file mode 100644 index 0000000000..ec88db4d0f --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityNameQueryProcessor.java @@ -0,0 +1,41 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.EntityNameFilter; +import org.thingsboard.server.edqs.data.EntityData; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; +import org.thingsboard.server.edqs.util.RepositoryUtils; + +import java.util.regex.Pattern; + +public class EntityNameQueryProcessor extends AbstractSimpleQueryProcessor { + + private final Pattern pattern; + + public EntityNameQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query) { + super(repo, ctx, query, (EntityNameFilter) query.getEntityFilter(), ((EntityNameFilter) query.getEntityFilter()).getEntityType()); + pattern = RepositoryUtils.toSqlLikePattern(filter.getEntityNameFilter()); + } + + @Override + protected boolean matches(EntityData ed) { + return ed.getFields() != null && (pattern == null || pattern.matcher(ed.getFields().getName()).matches()); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityQueryProcessor.java new file mode 100644 index 0000000000..ad3fc0e7b6 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityQueryProcessor.java @@ -0,0 +1,28 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.edqs.query.SortableEntityData; + +import java.util.List; + +public interface EntityQueryProcessor { + + List processQuery(); + + long count(); + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityQueryProcessorFactory.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityQueryProcessorFactory.java new file mode 100644 index 0000000000..12fc863566 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityQueryProcessorFactory.java @@ -0,0 +1,44 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; + +public class EntityQueryProcessorFactory { + + public static EntityQueryProcessor create(TenantRepo repo, QueryContext ctx, EdqsQuery query) { + return switch (query.getEntityFilter().getType()) { + case SINGLE_ENTITY -> new SingleEntityQueryProcessor(repo, ctx, query); + case ENTITY_LIST -> new EntityListQueryProcessor(repo, ctx, query); + case ENTITY_NAME -> new EntityNameQueryProcessor(repo, ctx, query); + case ENTITY_TYPE -> new EntityTypeQueryProcessor(repo, ctx, query); + case DEVICE_TYPE -> new DeviceTypeQueryProcessor(repo, ctx, query); + case ASSET_TYPE -> new AssetTypeQueryProcessor(repo, ctx, query); + case ENTITY_VIEW_TYPE -> new EntityViewTypeQueryProcessor(repo, ctx, query); + case EDGE_TYPE -> new EdgeTypeQueryProcessor(repo, ctx, query); + case RELATIONS_QUERY -> new RelationQueryProcessor(repo, ctx, query); + case API_USAGE_STATE -> new ApiUsageStateQueryProcessor(repo, ctx, query); + case ASSET_SEARCH_QUERY -> new AssetSearchQueryProcessor(repo, ctx, query); + case DEVICE_SEARCH_QUERY -> new DeviceSearchQueryProcessor(repo, ctx, query); + case ENTITY_VIEW_SEARCH_QUERY -> new EntityViewSearchQueryProcessor(repo, ctx, query); + case EDGE_SEARCH_QUERY -> new EdgeTypeSearchQueryProcessor(repo, ctx, query); + default -> throw new RuntimeException("Not Implemented!"); + }; + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityTypeQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityTypeQueryProcessor.java new file mode 100644 index 0000000000..6f6ec3d007 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityTypeQueryProcessor.java @@ -0,0 +1,35 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.EntityTypeFilter; +import org.thingsboard.server.edqs.data.EntityData; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; + +public class EntityTypeQueryProcessor extends AbstractSimpleQueryProcessor { + + public EntityTypeQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query) { + super(repo, ctx, query, (EntityTypeFilter) query.getEntityFilter(), ((EntityTypeFilter) query.getEntityFilter()).getEntityType()); + } + + @Override + protected boolean matches(EntityData ed) { + return super.matches(ed); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityViewSearchQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityViewSearchQueryProcessor.java new file mode 100644 index 0000000000..80f56c2169 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityViewSearchQueryProcessor.java @@ -0,0 +1,44 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.EntityViewSearchQueryFilter; +import org.thingsboard.server.edqs.data.EntityData; +import org.thingsboard.server.edqs.data.RelationInfo; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; + +public class EntityViewSearchQueryProcessor extends AbstractEntitySearchQueryProcessor { + + public EntityViewSearchQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query) { + super(repo, ctx, query, (EntityViewSearchQueryFilter) query.getEntityFilter()); + } + + @Override + public EntityType getEntityType() { + return EntityType.ENTITY_VIEW; + } + + @Override + protected boolean check(RelationInfo relationInfo) { + EntityData ed = relationInfo.getTarget(); + return super.check(relationInfo) && + (filter.getEntityViewTypes() == null || filter.getEntityViewTypes().contains(ed.getFields().getType())); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityViewTypeQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityViewTypeQueryProcessor.java new file mode 100644 index 0000000000..2ce4e8616b --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/EntityViewTypeQueryProcessor.java @@ -0,0 +1,42 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.EntityViewTypeFilter; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; + +import java.util.List; + +public class EntityViewTypeQueryProcessor extends AbstractEntityProfileNameQueryProcessor { + + public EntityViewTypeQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query) { + super(repo, ctx, query, (EntityViewTypeFilter) query.getEntityFilter(), EntityType.ENTITY_VIEW); + } + + @Override + protected String getEntityNameFilter(EntityViewTypeFilter filter) { + return filter.getEntityViewNameFilter(); + } + + @Override + protected List getProfileNames(EntityViewTypeFilter filter) { + return filter.getEntityViewTypes(); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/RelationQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/RelationQueryProcessor.java new file mode 100644 index 0000000000..d4928a5832 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/RelationQueryProcessor.java @@ -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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.RelationsQueryFilter; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.edqs.data.RelationInfo; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; + +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +public class RelationQueryProcessor extends AbstractRelationQueryProcessor { + + private final boolean hasFilters; + + public RelationQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query) { + super(repo, ctx, query, (RelationsQueryFilter) query.getEntityFilter()); + this.hasFilters = filter.getFilters() != null && !filter.getFilters().isEmpty(); + } + + @Override + public Set getRootEntities() { + if (filter.isMultiRoot()) { + return filter.getMultiRootEntityIds().stream().map(UUID::fromString).collect(Collectors.toSet()); + } else { + return Set.of(filter.getRootEntity().getId()); + } + } + + @Override + public EntitySearchDirection getDirection() { + return filter.getDirection(); + } + + @Override + public int getMaxLevel() { + return filter.getMaxLevel(); + } + + @Override + public boolean isMultiRoot() { + return filter.isMultiRoot(); + } + + @Override + public boolean isFetchLastLevelOnly() { + return filter.isFetchLastLevelOnly(); + } + + @Override + protected boolean check(RelationInfo relationInfo) { + if (hasFilters) { + for (var f : filter.getFilters()) { + if (((!filter.isNegate() && !f.isNegate()) || (filter.isNegate() && f.isNegate())) == f.getRelationType().equals(relationInfo.getType())) { + if (f.getEntityTypes() == null || f.getEntityTypes().isEmpty() + || f.getEntityTypes().contains(relationInfo.getTarget().getEntityType())) { + return super.matches(relationInfo.getTarget()); + } + } + } + return false; + } else { + return super.matches(relationInfo.getTarget()); + } + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/SingleEntityQueryProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/SingleEntityQueryProcessor.java new file mode 100644 index 0000000000..febf228699 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/processor/SingleEntityQueryProcessor.java @@ -0,0 +1,61 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.query.processor; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.SingleEntityFilter; +import org.thingsboard.server.edqs.data.EntityData; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.repo.TenantRepo; + +import java.util.UUID; +import java.util.function.Consumer; + +public class SingleEntityQueryProcessor extends AbstractSingleEntityTypeQueryProcessor { + + private final EntityType entityType; + private final UUID entityId; + + public SingleEntityQueryProcessor(TenantRepo repo, QueryContext ctx, EdqsQuery query) { + super(repo, ctx, query, (SingleEntityFilter) query.getEntityFilter()); + this.entityType = filter.getSingleEntity().getEntityType(); + this.entityId = filter.getSingleEntity().getId(); + } + + @Override + protected void processCustomerQuery(UUID customerId, Consumer> processor) { + processAll(ed -> { + if (checkCustomerId(customerId, ed)) { + processor.accept(ed); + } + }); + } + + @Override + protected void processAll(Consumer> processor) { + EntityData ed = repository.getEntityMap(entityType).get(entityId); + if (matches(ed)) { + processor.accept(ed); + } + } + + @Override + protected int getProbableResultSize() { + return 1; + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/repo/DefaultEdqsRepository.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/repo/DefaultEdqsRepository.java new file mode 100644 index 0000000000..1deaca83a7 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/repo/DefaultEdqsRepository.java @@ -0,0 +1,90 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.edqs.EdqsEvent; +import org.thingsboard.server.common.data.edqs.EdqsEventType; +import org.thingsboard.server.common.data.edqs.query.QueryResult; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.edqs.stats.EdqsStatsService; +import org.thingsboard.server.queue.edqs.EdqsComponent; + +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Predicate; + +@EdqsComponent +@AllArgsConstructor +@Service +@Slf4j +public class DefaultEdqsRepository implements EdqsRepository { + + private final static ConcurrentMap repos = new ConcurrentHashMap<>(); + private final Optional statsService; + + public TenantRepo get(TenantId tenantId) { + return repos.computeIfAbsent(tenantId, id -> new TenantRepo(id, statsService)); + } + + @Override + public void processEvent(EdqsEvent event) { + if (event.getEventType() == EdqsEventType.DELETED && event.getObjectType() == ObjectType.TENANT) { + log.info("Tenant {} deleted", event.getTenantId()); + repos.remove(event.getTenantId()); + } else { + get(event.getTenantId()).processEvent(event); + } + } + + @Override + public long countEntitiesByQuery(TenantId tenantId, CustomerId customerId, EntityCountQuery query, boolean ignorePermissionCheck) { + long startNs = System.nanoTime(); + long result = get(tenantId).countEntitiesByQuery(customerId, query, ignorePermissionCheck); + double timingMs = (double) (System.nanoTime() - startNs) / 1000_000; + log.info("countEntitiesByQuery done in {} ms", timingMs); + return result; + } + + @Override + public PageData findEntityDataByQuery(TenantId tenantId, CustomerId customerId, + EntityDataQuery query, boolean ignorePermissionCheck) { + long startNs = System.nanoTime(); + var result = get(tenantId).findEntityDataByQuery(customerId, query, ignorePermissionCheck); + double timingMs = (double) (System.nanoTime() - startNs) / 1000_000; + log.info("findEntityDataByQuery done in {} ms", timingMs); + return result; + } + + @Override + public void clearIf(Predicate predicate) { + repos.keySet().removeIf(predicate); + } + + @Override + public void clear() { + repos.clear(); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/repo/EdqsRepository.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/repo/EdqsRepository.java new file mode 100644 index 0000000000..3d9f2ab8df --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/repo/EdqsRepository.java @@ -0,0 +1,40 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import org.thingsboard.server.common.data.edqs.EdqsEvent; +import org.thingsboard.server.common.data.edqs.query.QueryResult; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.common.data.query.EntityDataQuery; + +import java.util.function.Predicate; + +public interface EdqsRepository { + + void processEvent(EdqsEvent event); + + long countEntitiesByQuery(TenantId tenantId, CustomerId customerId, EntityCountQuery query, boolean ignorePermissionCheck); + + PageData findEntityDataByQuery(TenantId tenantId, CustomerId customerId, EntityDataQuery query, boolean ignorePermissionCheck); + + void clearIf(Predicate predicate); + + void clear(); + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/repo/KeyDictionary.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/repo/KeyDictionary.java new file mode 100644 index 0000000000..0a79b300a9 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/repo/KeyDictionary.java @@ -0,0 +1,40 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicInteger; + +public class KeyDictionary { + + private static final ConcurrentMap keyToIdDict = new ConcurrentHashMap<>(); + private static final ConcurrentMap idToKeyDict = new ConcurrentHashMap<>(); + private static final AtomicInteger keySeq = new AtomicInteger(); + + public static Integer get(String key) { + return keyToIdDict.computeIfAbsent(key, __ -> { + int keyId = keySeq.incrementAndGet(); + idToKeyDict.put(keyId, key); + return keyId; + }); + } + + public static String get(Integer keyId) { + return idToKeyDict.get(keyId); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/repo/TenantRepo.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/repo/TenantRepo.java new file mode 100644 index 0000000000..ab7fb3acff --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/repo/TenantRepo.java @@ -0,0 +1,442 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.edqs.AttributeKv; +import org.thingsboard.server.common.data.edqs.DataPoint; +import org.thingsboard.server.common.data.edqs.EdqsEvent; +import org.thingsboard.server.common.data.edqs.EdqsEventType; +import org.thingsboard.server.common.data.edqs.EdqsObject; +import org.thingsboard.server.common.data.edqs.Entity; +import org.thingsboard.server.common.data.edqs.LatestTsKv; +import org.thingsboard.server.common.data.edqs.fields.AssetFields; +import org.thingsboard.server.common.data.edqs.fields.EntityFields; +import org.thingsboard.server.common.data.edqs.query.QueryResult; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityFilter; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.TsValue; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.edqs.data.ApiUsageStateData; +import org.thingsboard.server.edqs.data.AssetData; +import org.thingsboard.server.edqs.data.CustomerData; +import org.thingsboard.server.edqs.data.DeviceData; +import org.thingsboard.server.edqs.data.EntityData; +import org.thingsboard.server.edqs.data.EntityProfileData; +import org.thingsboard.server.edqs.data.GenericData; +import org.thingsboard.server.edqs.data.RelationsRepo; +import org.thingsboard.server.edqs.data.TenantData; +import org.thingsboard.server.edqs.query.EdqsDataQuery; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.query.SortableEntityData; +import org.thingsboard.server.edqs.query.processor.EntityQueryProcessor; +import org.thingsboard.server.edqs.query.processor.EntityQueryProcessorFactory; +import org.thingsboard.server.edqs.stats.EdqsStatsService; +import org.thingsboard.server.edqs.util.RepositoryUtils; +import org.thingsboard.server.edqs.util.TbStringPool; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ConcurrentSkipListSet; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +import static org.thingsboard.server.edqs.util.RepositoryUtils.SORT_ASC; +import static org.thingsboard.server.edqs.util.RepositoryUtils.SORT_DESC; +import static org.thingsboard.server.edqs.util.RepositoryUtils.resolveEntityType; + +@Slf4j +public class TenantRepo { + + public static final Comparator> CREATED_TIME_COMPARATOR = Comparator.comparingLong(ed -> ed.getFields().getCreatedTime()); + public static final Comparator> CREATED_TIME_AND_ID_COMPARATOR = CREATED_TIME_COMPARATOR + .thenComparing(EntityData::getId); + public static final Comparator> CREATED_TIME_AND_ID_DESC_COMPARATOR = CREATED_TIME_AND_ID_COMPARATOR.reversed(); + + private final ConcurrentMap>> entitySetByType = new ConcurrentHashMap<>(); + private final ConcurrentMap>> entityMapByType = new ConcurrentHashMap<>(); + private final ConcurrentMap relations = new ConcurrentHashMap<>(); + + private final Lock entityUpdateLock = new ReentrantLock(); + + private final TenantId tenantId; + private final Optional edqsStatsService; + + public TenantRepo(TenantId tenantId, Optional edqsStatsService) { + this.tenantId = tenantId; + this.edqsStatsService = edqsStatsService; + } + + public void processEvent(EdqsEvent event) { + EdqsObject edqsObject = event.getObject(); + log.trace("[{}] Processing event: {}", tenantId, event); + if (event.getEventType() == EdqsEventType.UPDATED) { + addOrUpdate(edqsObject); + } else if (event.getEventType() == EdqsEventType.DELETED) { + remove(edqsObject); + } + } + + public void addOrUpdate(EdqsObject object) { + if (object instanceof EntityRelation relation) { + addOrUpdateRelation(relation); + } else if (object instanceof AttributeKv attributeKv) { + addOrUpdateAttribute(attributeKv); + } else if (object instanceof LatestTsKv latestTsKv) { + addOrUpdateLatestKv(latestTsKv); + } else if (object instanceof Entity entity) { + addOrUpdateEntity(entity); + } + } + + public void remove(EdqsObject object) { + if (object instanceof EntityRelation relation) { + removeRelation(relation); + } else if (object instanceof AttributeKv attributeKv) { + removeAttribute(attributeKv); + } else if (object instanceof LatestTsKv latestTsKv) { + removeLatestKv(latestTsKv); + } else if (object instanceof Entity entity) { + removeEntity(entity); + } + } + + private void addOrUpdateRelation(EntityRelation entity) { + entityUpdateLock.lock(); + try { + if (RelationTypeGroup.COMMON.equals(entity.getTypeGroup())) { + RelationsRepo repo = relations.computeIfAbsent(entity.getTypeGroup(), tg -> new RelationsRepo()); + EntityData from = getOrCreate(entity.getFrom()); + EntityData to = getOrCreate(entity.getTo()); + boolean added = repo.add(from, to, TbStringPool.intern(entity.getType())); + if (added) { + edqsStatsService.ifPresent(statService -> statService.reportEvent(tenantId, ObjectType.RELATION, EdqsEventType.UPDATED)); + } + } else if (RelationTypeGroup.DASHBOARD.equals(entity.getTypeGroup())) { + if (EntityRelation.CONTAINS_TYPE.equals(entity.getType()) && entity.getFrom().getEntityType() == EntityType.CUSTOMER) { + ((CustomerData) getEntityMap(EntityType.CUSTOMER).computeIfAbsent(entity.getFrom().getId(), CustomerData::new)) + .addOrUpdate(getEntityMap(EntityType.DASHBOARD).get(entity.getTo().getId())); + } + } + } finally { + entityUpdateLock.unlock(); + } + } + + private void removeRelation(EntityRelation entityRelation) { + if (RelationTypeGroup.COMMON.equals(entityRelation.getTypeGroup())) { + RelationsRepo relationsRepo = relations.get(entityRelation.getTypeGroup()); + if (relationsRepo != null) { + boolean removed = relationsRepo.remove(entityRelation.getFrom().getId(), entityRelation.getTo().getId(), entityRelation.getType()); + if (removed) { + edqsStatsService.ifPresent(statService -> statService.reportEvent(tenantId, ObjectType.RELATION, EdqsEventType.DELETED)); + } + } + } else if (RelationTypeGroup.DASHBOARD.equals(entityRelation.getTypeGroup())) { + if (EntityRelation.CONTAINS_TYPE.equals(entityRelation.getType()) && entityRelation.getFrom().getEntityType() == EntityType.CUSTOMER) { + ((CustomerData) getEntityMap(EntityType.CUSTOMER).computeIfAbsent(entityRelation.getFrom().getId(), CustomerData::new)) + .remove(getEntityMap(EntityType.DASHBOARD).get(entityRelation.getTo().getId())); + } + } + } + + private void addOrUpdateEntity(Entity entity) { + entityUpdateLock.lock(); + try { + log.trace("[{}] addOrUpdateEntity: {}", tenantId, entity); + EntityFields fields = entity.getFields(); + UUID entityId = fields.getId(); + EntityType entityType = entity.getType(); + + EntityData entityData = getOrCreate(entityType, entityId); + processFields(fields); + EntityFields oldFields = entityData.getFields(); + entityData.setFields(fields); + if (oldFields == null) { + getEntitySet(entityType).add(entityData); + } + + UUID newCustomerId = fields.getCustomerId(); + UUID oldCustomerId = entityData.getCustomerId(); + entityData.setCustomerId(newCustomerId); + if (entityIdMismatch(oldCustomerId, newCustomerId)) { + if (oldCustomerId != null) { + CustomerData old = (CustomerData) getEntityMap(EntityType.CUSTOMER).get(oldCustomerId); + if (old != null) { + old.remove(entityData); + } + } + if (newCustomerId != null) { + CustomerData newData = (CustomerData) getEntityMap(EntityType.CUSTOMER).computeIfAbsent(newCustomerId, CustomerData::new); + newData.addOrUpdate(entityData); + } + } + } finally { + entityUpdateLock.unlock(); + } + } + + public void removeEntity(Entity entity) { + entityUpdateLock.lock(); + try { + UUID entityId = entity.getFields().getId(); + EntityType entityType = entity.getType(); + EntityData removed = getEntityMap(entityType).remove(entityId); + if (removed != null) { + if (removed.getFields() != null) { + getEntitySet(entityType).remove(removed); + } + edqsStatsService.ifPresent(statService -> statService.reportEvent(tenantId, ObjectType.fromEntityType(entityType), EdqsEventType.DELETED)); + UUID customerId = removed.getCustomerId(); + if (customerId != null) { + CustomerData customerData = (CustomerData) getEntityMap(EntityType.CUSTOMER).get(customerId); + if (customerData != null) { + customerData.remove(removed); + } + } + } + } finally { + entityUpdateLock.unlock(); + } + } + + public void addOrUpdateAttribute(AttributeKv attributeKv) { + var entityData = getOrCreate(attributeKv.getEntityId()); + if (entityData != null) { + Integer keyId = KeyDictionary.get(attributeKv.getKey()); + boolean added = entityData.putAttr(keyId, attributeKv.getScope(), attributeKv.getDataPoint()); + if (added) { + edqsStatsService.ifPresent(statService -> statService.reportEvent(tenantId, ObjectType.ATTRIBUTE_KV, EdqsEventType.UPDATED)); + } + } + } + + private void removeAttribute(AttributeKv attributeKv) { + var entityData = get(attributeKv.getEntityId()); + if (entityData != null) { + boolean removed = entityData.removeAttr(KeyDictionary.get(attributeKv.getKey()), attributeKv.getScope()); + if (removed) { + edqsStatsService.ifPresent(statService -> statService.reportEvent(tenantId, ObjectType.ATTRIBUTE_KV, EdqsEventType.DELETED)); + } + } + } + + public void addOrUpdateLatestKv(LatestTsKv latestTsKv) { + var entityData = getOrCreate(latestTsKv.getEntityId()); + if (entityData != null) { + Integer keyId = KeyDictionary.get(latestTsKv.getKey()); + boolean added = entityData.putTs(keyId, latestTsKv.getDataPoint()); + if (added) { + edqsStatsService.ifPresent(statService -> statService.reportEvent(tenantId, ObjectType.LATEST_TS_KV, EdqsEventType.UPDATED)); + } + } + } + + private void removeLatestKv(LatestTsKv latestTsKv) { + var entityData = get(latestTsKv.getEntityId()); + if (entityData != null) { + boolean removed = entityData.removeTs(KeyDictionary.get(latestTsKv.getKey())); + if (removed) { + edqsStatsService.ifPresent(statService -> statService.reportEvent(tenantId, ObjectType.LATEST_TS_KV, EdqsEventType.DELETED)); + } + } + } + + public void processFields(EntityFields fields) { + if (fields instanceof AssetFields assetFields) { + assetFields.setType(TbStringPool.intern(assetFields.getType())); + } + } + + public ConcurrentMap> getEntityMap(EntityType entityType) { + return entityMapByType.computeIfAbsent(entityType, et -> new ConcurrentHashMap<>()); + } + + //TODO: automatically remove entities that has nothing except the ID. + private EntityData getOrCreate(EntityId entityId) { + return getOrCreate(entityId.getEntityType(), entityId.getId()); + } + + private EntityData getOrCreate(EntityType entityType, UUID entityId) { + return getEntityMap(entityType).computeIfAbsent(entityId, id -> { + log.debug("[{}] Adding {} {}", tenantId, entityType, id); + EntityData entityData = constructEntityData(entityType, entityId); + edqsStatsService.ifPresent(statService -> statService.reportEvent(tenantId, ObjectType.fromEntityType(entityType), EdqsEventType.UPDATED)); + return entityData; + }); + } + + private EntityData get(EntityId entityId) { + return getEntityMap(entityId.getEntityType()).get(entityId.getId()); + } + + private EntityData constructEntityData(EntityType entityType, UUID id) { + EntityData entityData = switch (entityType) { + case DEVICE -> new DeviceData(id); + case ASSET -> new AssetData(id); + case DEVICE_PROFILE, ASSET_PROFILE -> new EntityProfileData(id, entityType); + case CUSTOMER -> new CustomerData(id); + case TENANT -> new TenantData(id); + case API_USAGE_STATE -> new ApiUsageStateData(id); + default -> new GenericData(entityType, id); + }; + entityData.setRepo(this); + return entityData; + } + + private static boolean entityIdMismatch(UUID oldOrNull, UUID newOrNull) { + if (oldOrNull == null) { + return newOrNull != null; + } else { + return !oldOrNull.equals(newOrNull); + } + } + + public Set> getEntitySet(EntityType entityType) { + return entitySetByType.computeIfAbsent(entityType, et -> new ConcurrentSkipListSet<>(CREATED_TIME_AND_ID_DESC_COMPARATOR)); + } + + public PageData findEntityDataByQuery(CustomerId customerId, EntityDataQuery oldQuery, boolean ignorePermissionCheck) { + EdqsDataQuery query = RepositoryUtils.toNewQuery(oldQuery); + log.info("[{}][{}] findEntityDataByQuery: {}", tenantId, customerId, query); + QueryContext ctx = buildContext(customerId, query.getEntityFilter(), ignorePermissionCheck); + EntityQueryProcessor queryProcessor = EntityQueryProcessorFactory.create(this, ctx, query); + return sortAndConvert(query, queryProcessor.processQuery(), ctx); + } + + public long countEntitiesByQuery(CustomerId customerId, EntityCountQuery oldQuery, boolean ignorePermissionCheck) { + EdqsQuery query = RepositoryUtils.toNewQuery(oldQuery); + log.info("[{}][{}] countEntitiesByQuery: {}", tenantId, customerId, query); + QueryContext ctx = buildContext(customerId, query.getEntityFilter(), ignorePermissionCheck); + EntityQueryProcessor queryProcessor = EntityQueryProcessorFactory.create(this, ctx, query); + return queryProcessor.count(); + } + + private PageData sortAndConvert(EdqsDataQuery query, List data, QueryContext ctx) { + int totalSize = data.size(); + int totalPages = (int) Math.ceil((float) totalSize / query.getPageSize()); + int offset = query.getPage() * query.getPageSize(); + if (offset > totalSize) { + return new PageData<>(Collections.emptyList(), totalPages, totalSize, false); + } else { + Comparator comparator = EntityDataSortOrder.Direction.ASC.equals(query.getSortDirection()) ? SORT_ASC : SORT_DESC; + long startTs = System.nanoTime(); +// IMPLEMENTATION THAT IS BASED ON PRIORITY_QUEUE +// var requiredSize = Math.min(offset + query.getPageSize(), totalSize); +// PriorityQueue topN = new PriorityQueue<>(requiredSize, comparator.reversed()); +// for (SortableEntityData item : data) { +// topN.add(item); +// if (topN.size() > requiredSize) { +// topN.poll(); +// } +// } +// List result = new ArrayList<>(topN); +// Collections.reverse(result); +// result = result.subList(offset, requiredSize); +// IMPLEMENTATION THAT IS BASED ON TREE SET (For offset + query.getPageSize() << totalSize) + var requiredSize = Math.min(offset + query.getPageSize(), totalSize); + TreeSet topNSet = new TreeSet<>(comparator); + for (SortableEntityData sp : data) { + topNSet.add(sp); + if (topNSet.size() > requiredSize) { + topNSet.pollLast(); + } + } + var result = topNSet.stream().skip(offset).limit(query.getPageSize()).collect(Collectors.toList()); +// IMPLEMENTATION THAT IS BASED ON TIM SORT (For offset + query.getPageSize() > totalSize / 2) +// data.sort(comparator); +// var result = data.subList(offset, endIndex); + log.trace("EDQ Sorted in {}", TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTs)); + return new PageData<>(toQueryResult(result, query, ctx), totalPages, totalSize, totalSize > requiredSize); + } + } + + private List toQueryResult(List data, EdqsDataQuery query, QueryContext ctx) { + long ts = System.currentTimeMillis(); + List results = new ArrayList<>(data.size()); + for (SortableEntityData entityData : data) { + Map> latest = new HashMap<>(); + for (var key : query.getEntityFields()) { + DataPoint dp = entityData.getEntityData().getDataPoint(key, ctx); + TsValue v = RepositoryUtils.toTsValue(ts, dp); + latest.computeIfAbsent(EntityKeyType.ENTITY_FIELD, t -> new HashMap<>()).put(key.key(), v); + } + for (var key : query.getLatestValues()) { + DataPoint dp = entityData.getEntityData().getDataPoint(key, ctx); + TsValue v = RepositoryUtils.toTsValue(ts, dp); + latest.computeIfAbsent(key.type(), t -> new HashMap<>()).put(KeyDictionary.get(key.keyId()), v); + } + + results.add(new QueryResult(entityData.getEntityId(), latest)); + } + return results; + } + + private QueryContext buildContext(CustomerId customerId, EntityFilter filter, boolean ignorePermissionCheck) { + return new QueryContext(tenantId, customerId, resolveEntityType(filter), ignorePermissionCheck); + } + + public TenantId getTenantId() { + return tenantId; + } + + + public RelationsRepo getRelations(RelationTypeGroup relationTypeGroup) { + return relations.computeIfAbsent(relationTypeGroup, type -> new RelationsRepo()); + } + + public String getOwnerName(EntityId ownerId) { + if (ownerId == null || (ownerId.getEntityType() == EntityType.CUSTOMER && ownerId.isNullUid())) { + return getOwnerEntityName(tenantId); + } + return getOwnerEntityName(ownerId); + } + + private String getOwnerEntityName(EntityId entityId) { + EntityType entityType = entityId.getEntityType(); + return switch (entityType) { + case CUSTOMER, TENANT -> { + EntityFields fields = getEntityMap(entityType).get(entityId.getId()).getFields(); + yield fields != null ? fields.getName() : ""; + } + default -> throw new RuntimeException("Unsupported entity type: " + entityType); + }; + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/state/EdqsPartitionService.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/state/EdqsPartitionService.java new file mode 100644 index 0000000000..94e9437650 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/state/EdqsPartitionService.java @@ -0,0 +1,40 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.state; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.queue.discovery.HashPartitionService; +import org.thingsboard.server.queue.edqs.EdqsConfig; +import org.thingsboard.server.queue.edqs.EdqsConfig.EdqsPartitioningStrategy; + +@Service +@RequiredArgsConstructor +public class EdqsPartitionService { + + private final HashPartitionService hashPartitionService; + private final EdqsConfig edqsConfig; + + public Integer resolvePartition(TenantId tenantId) { + if (edqsConfig.getPartitioningStrategy() == EdqsPartitioningStrategy.TENANT) { + return hashPartitionService.resolvePartitionIndex(tenantId.getId(), edqsConfig.getPartitions()); + } else { + return null; + } + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/state/EdqsStateService.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/state/EdqsStateService.java new file mode 100644 index 0000000000..ee7b058a8a --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/state/EdqsStateService.java @@ -0,0 +1,40 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.state; + +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.edqs.EdqsEventType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; + +import java.util.Set; + +public interface EdqsStateService { + + void init(PartitionedQueueConsumerManager> eventConsumer); + + void process(Set partitions); + + void save(TenantId tenantId, ObjectType type, String key, EdqsEventType eventType, ToEdqsMsg msg); + + boolean isReady(); + + void stop(); + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/state/KafkaEdqsStateService.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/state/KafkaEdqsStateService.java new file mode 100644 index 0000000000..85b8e92387 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/state/KafkaEdqsStateService.java @@ -0,0 +1,188 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.state; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.edqs.EdqsEventType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.edqs.processor.EdqsProcessor; +import org.thingsboard.server.edqs.processor.EdqsProducer; +import org.thingsboard.server.edqs.util.VersionsStore; +import org.thingsboard.server.gen.transport.TransportProtos.EdqsEventMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; +import org.thingsboard.server.queue.common.consumer.QueueConsumerManager; +import org.thingsboard.server.queue.common.state.KafkaQueueStateService; +import org.thingsboard.server.queue.common.state.QueueStateService; +import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.queue.discovery.TopicService; +import org.thingsboard.server.queue.edqs.EdqsConfig; +import org.thingsboard.server.queue.edqs.EdqsQueue; +import org.thingsboard.server.queue.edqs.EdqsQueueFactory; +import org.thingsboard.server.queue.edqs.KafkaEdqsComponent; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +@Service +@RequiredArgsConstructor +@KafkaEdqsComponent +@Slf4j +public class KafkaEdqsStateService implements EdqsStateService { + + private final EdqsConfig config; + private final EdqsPartitionService partitionService; + private final EdqsQueueFactory queueFactory; + private final TopicService topicService; + @Autowired @Lazy + private EdqsProcessor edqsProcessor; + + private PartitionedQueueConsumerManager> stateConsumer; + private QueueStateService, TbProtoQueueMsg> queueStateService; + private QueueConsumerManager> eventsToBackupConsumer; + private EdqsProducer stateProducer; + + private final VersionsStore versionsStore = new VersionsStore(); + private final AtomicInteger stateReadCount = new AtomicInteger(); + private final AtomicInteger eventsReadCount = new AtomicInteger(); + private Boolean ready; + + @Override + public void init(PartitionedQueueConsumerManager> eventConsumer) { + stateConsumer = PartitionedQueueConsumerManager.>create() + .queueKey(new QueueKey(ServiceType.EDQS, EdqsQueue.STATE.getTopic())) + .topic(EdqsQueue.STATE.getTopic()) + .pollInterval(config.getPollInterval()) + .msgPackProcessor((msgs, consumer, config) -> { + for (TbProtoQueueMsg queueMsg : msgs) { + try { + ToEdqsMsg msg = queueMsg.getValue(); + edqsProcessor.process(msg, EdqsQueue.STATE); + if (stateReadCount.incrementAndGet() % 100000 == 0) { + log.info("[state] Processed {} msgs", stateReadCount.get()); + } + } catch (Exception e) { + log.error("Failed to process message: {}", queueMsg, e); + } + } + consumer.commit(); + }) + .consumerCreator((config, partitionId) -> queueFactory.createEdqsMsgConsumer(EdqsQueue.STATE)) + .queueAdmin(queueFactory.getEdqsQueueAdmin()) + .consumerExecutor(eventConsumer.getConsumerExecutor()) + .taskExecutor(eventConsumer.getTaskExecutor()) + .scheduler(eventConsumer.getScheduler()) + .uncaughtErrorHandler(edqsProcessor.getErrorHandler()) + .build(); + queueStateService = new KafkaQueueStateService<>(eventConsumer, stateConsumer); + + eventsToBackupConsumer = QueueConsumerManager.>builder() + .name("edqs-events-to-backup-consumer") + .pollInterval(config.getPollInterval()) + .msgPackProcessor((msgs, consumer) -> { + for (TbProtoQueueMsg queueMsg : msgs) { + if (consumer.isStopped()) { + return; + } + try { + ToEdqsMsg msg = queueMsg.getValue(); + log.trace("Processing message: {}", msg); + + if (msg.hasEventMsg()) { + EdqsEventMsg eventMsg = msg.getEventMsg(); + String key = eventMsg.getKey(); + int count = eventsReadCount.incrementAndGet(); + if (count % 100000 == 0) { + log.info("[events-to-backup] Processed {} msgs", count); + } + if (eventMsg.hasVersion()) { + if (!versionsStore.isNew(key, eventMsg.getVersion())) { + continue; + } + } + + TenantId tenantId = getTenantId(msg); + ObjectType objectType = ObjectType.valueOf(eventMsg.getObjectType()); + EdqsEventType eventType = EdqsEventType.valueOf(eventMsg.getEventType()); + log.trace("[{}] Saving to backup [{}] [{}] [{}]", tenantId, objectType, eventType, key); + stateProducer.send(tenantId, objectType, key, msg); + } + } catch (Throwable t) { + log.error("Failed to process message: {}", queueMsg, t); + } + } + consumer.commit(); + }) + .consumerCreator(() -> queueFactory.createEdqsMsgConsumer(EdqsQueue.EVENTS, "events-to-backup-consumer-group")) // shared by all instances consumer group + .consumerExecutor(eventConsumer.getConsumerExecutor()) + .threadPrefix("edqs-events-to-backup") + .build(); + + stateProducer = EdqsProducer.builder() + .queue(EdqsQueue.STATE) + .partitionService(partitionService) + .topicService(topicService) + .producer(queueFactory.createEdqsMsgProducer(EdqsQueue.STATE)) + .build(); + } + + @Override + public void process(Set partitions) { + if (queueStateService.getPartitions().isEmpty()) { + eventsToBackupConsumer.subscribe(); + eventsToBackupConsumer.launch(); + } + queueStateService.update(new QueueKey(ServiceType.EDQS), partitions); + } + + @Override + public void save(TenantId tenantId, ObjectType type, String key, EdqsEventType eventType, ToEdqsMsg msg) { + // do nothing here, backup is done by events consumer + } + + @Override + public boolean isReady() { + if (ready == null) { + Set partitionsInProgress = queueStateService.getPartitionsInProgress(); + if (partitionsInProgress != null && partitionsInProgress.isEmpty()) { + ready = true; // once true - always true, not to change readiness status on each repartitioning + } + } + return ready != null && ready; + } + + private TenantId getTenantId(ToEdqsMsg edqsMsg) { + return TenantId.fromUUID(new UUID(edqsMsg.getTenantIdMSB(), edqsMsg.getTenantIdLSB())); + } + + @Override + public void stop() { + stateConsumer.stop(); + stateConsumer.awaitStop(); + eventsToBackupConsumer.stop(); + stateProducer.stop(); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/state/LocalEdqsStateService.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/state/LocalEdqsStateService.java new file mode 100644 index 0000000000..383115ddf1 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/state/LocalEdqsStateService.java @@ -0,0 +1,98 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.state; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.edqs.EdqsEventType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.edqs.processor.EdqsProcessor; +import org.thingsboard.server.edqs.util.EdqsRocksDb; +import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; +import org.thingsboard.server.queue.edqs.EdqsQueue; +import org.thingsboard.server.queue.edqs.InMemoryEdqsComponent; + +import java.util.Set; + +import static org.thingsboard.server.common.msg.queue.TopicPartitionInfo.withTopic; + +@Service +@RequiredArgsConstructor +@InMemoryEdqsComponent +@Slf4j +public class LocalEdqsStateService implements EdqsStateService { + + private final EdqsRocksDb db; + @Autowired @Lazy + private EdqsProcessor processor; + + private PartitionedQueueConsumerManager> eventConsumer; + private Set partitions; + + @Override + public void init(PartitionedQueueConsumerManager> eventConsumer) { + this.eventConsumer = eventConsumer; + } + + @Override + public void process(Set partitions) { + if (this.partitions == null) { + db.forEach((key, value) -> { + try { + ToEdqsMsg edqsMsg = ToEdqsMsg.parseFrom(value); + log.trace("[{}] Restored msg from RocksDB: {}", key, edqsMsg); + processor.process(edqsMsg, EdqsQueue.STATE); + } catch (Exception e) { + log.error("[{}] Failed to restore value", key, e); + } + }); + log.info("Restore completed"); + } + eventConsumer.update(withTopic(partitions, EdqsQueue.EVENTS.getTopic())); + this.partitions = partitions; + } + + @Override + public void save(TenantId tenantId, ObjectType type, String key, EdqsEventType eventType, ToEdqsMsg msg) { + log.trace("Save to RocksDB: {} {} {} {}", tenantId, type, key, msg); + try { + if (eventType == EdqsEventType.DELETED) { + db.delete(key); + } else { + db.put(key, msg.toByteArray()); + } + } catch (Exception e) { + log.error("[{}] Failed to save event {}", key, msg, e); + } + } + + @Override + public boolean isReady() { + return partitions != null; + } + + @Override + public void stop() { + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/stats/EdqsStatsService.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/stats/EdqsStatsService.java new file mode 100644 index 0000000000..442453fc93 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/stats/EdqsStatsService.java @@ -0,0 +1,81 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.stats; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.edqs.EdqsEventType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.common.stats.StatsType; +import org.thingsboard.server.queue.edqs.EdqsComponent; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +@EdqsComponent +@Service +@Slf4j +@RequiredArgsConstructor +@ConditionalOnProperty(name = "queue.edqs.stats.enabled", havingValue = "true", matchIfMissing = true) +public class EdqsStatsService { + + private final ConcurrentHashMap statsMap = new ConcurrentHashMap<>(); + private final StatsFactory statsFactory; + + public void reportEvent(TenantId tenantId, ObjectType objectType, EdqsEventType eventType) { + statsMap.computeIfAbsent(tenantId, id -> new EdqsStats(tenantId, statsFactory)) + .reportEvent(objectType, eventType); + } + + @Getter + @AllArgsConstructor + static class EdqsStats { + + private final TenantId tenantId; + private final ConcurrentHashMap entityCounters = new ConcurrentHashMap<>(); + private final StatsFactory statsFactory; + + private AtomicInteger getOrCreateObjectCounter(ObjectType objectType) { + return entityCounters.computeIfAbsent(objectType, + type -> statsFactory.createGauge(StatsType.EDQS.getName() + "_object_count", new AtomicInteger(), + "tenantId", tenantId.toString(), "objectType", type.name())); + } + + @Override + public String toString() { + return entityCounters.entrySet().stream() + .map(counters -> counters.getKey().name()+ " total = [" + counters.getValue() + "]") + .collect(Collectors.joining(", ")); + } + + public void reportEvent(ObjectType objectType, EdqsEventType eventType) { + AtomicInteger objectCounter = getOrCreateObjectCounter(objectType); + if (eventType == EdqsEventType.UPDATED){ + objectCounter.incrementAndGet(); + } else if (eventType == EdqsEventType.DELETED) { + objectCounter.decrementAndGet(); + } + } + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/EdqsConverter.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/EdqsConverter.java new file mode 100644 index 0000000000..5b4cd7ac4a --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/EdqsConverter.java @@ -0,0 +1,253 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.util; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.google.protobuf.ByteString; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.edqs.AttributeKv; +import org.thingsboard.server.common.data.edqs.DataPoint; +import org.thingsboard.server.common.data.edqs.EdqsObject; +import org.thingsboard.server.common.data.edqs.Entity; +import org.thingsboard.server.common.data.edqs.LatestTsKv; +import org.thingsboard.server.common.data.edqs.fields.FieldsUtil; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.util.ProtoUtils; +import org.thingsboard.server.edqs.data.dp.BoolDataPoint; +import org.thingsboard.server.edqs.data.dp.CompressedJsonDataPoint; +import org.thingsboard.server.edqs.data.dp.CompressedStringDataPoint; +import org.thingsboard.server.edqs.data.dp.DoubleDataPoint; +import org.thingsboard.server.edqs.data.dp.JsonDataPoint; +import org.thingsboard.server.edqs.data.dp.LongDataPoint; +import org.thingsboard.server.edqs.data.dp.StringDataPoint; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.DataPointProto; +import org.xerial.snappy.Snappy; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@Service +@Slf4j +public class EdqsConverter { + + private final Map> converters = new HashMap<>(); + private final Converter defaultConverter = new JsonConverter<>(Entity.class); + + { + converters.put(ObjectType.RELATION, new JsonConverter<>(EntityRelation.class)); + converters.put(ObjectType.ATTRIBUTE_KV, new Converter() { + @Override + public byte[] serialize(ObjectType type, AttributeKv attributeKv) { + var proto = TransportProtos.AttributeKvProto.newBuilder() + .setEntityIdMSB(attributeKv.getEntityId().getId().getMostSignificantBits()) + .setEntityIdLSB(attributeKv.getEntityId().getId().getLeastSignificantBits()) + .setEntityType(ProtoUtils.toProto(attributeKv.getEntityId().getEntityType())) + .setScope(TransportProtos.AttributeScopeProto.forNumber(attributeKv.getScope().ordinal())) + .setKey(attributeKv.getKey()) + .setVersion(attributeKv.getVersion()); + if (attributeKv.getLastUpdateTs() != null && attributeKv.getValue() != null) { + proto.setDataPoint(toDataPointProto(attributeKv.getLastUpdateTs(), attributeKv.getValue())); + } + return proto.build().toByteArray(); + } + + @Override + public AttributeKv deserialize(ObjectType type, byte[] bytes) throws Exception { + TransportProtos.AttributeKvProto proto = TransportProtos.AttributeKvProto.parseFrom(bytes); + EntityId entityId = EntityIdFactory.getByTypeAndUuid(ProtoUtils.fromProto(proto.getEntityType()), + new UUID(proto.getEntityIdMSB(), proto.getEntityIdLSB())); + AttributeScope scope = AttributeScope.values()[proto.getScope().getNumber()]; + DataPoint dataPoint = proto.hasDataPoint() ? fromDataPointProto(proto.getDataPoint()) : null; + return AttributeKv.builder() + .entityId(entityId) + .scope(scope) + .key(proto.getKey()) + .version(proto.getVersion()) + .dataPoint(dataPoint) + .build(); + } + }); + converters.put(ObjectType.LATEST_TS_KV, new Converter() { + @Override + public byte[] serialize(ObjectType type, LatestTsKv latestTsKv) { + var proto = TransportProtos.LatestTsKvProto.newBuilder() + .setEntityIdMSB(latestTsKv.getEntityId().getId().getMostSignificantBits()) + .setEntityIdLSB(latestTsKv.getEntityId().getId().getLeastSignificantBits()) + .setEntityType(ProtoUtils.toProto(latestTsKv.getEntityId().getEntityType())) + .setKey(latestTsKv.getKey()) + .setVersion(latestTsKv.getVersion()); + if (latestTsKv.getTs() != null && latestTsKv.getValue() != null) { + proto.setDataPoint(toDataPointProto(latestTsKv.getTs(), latestTsKv.getValue())); + } + return proto.build().toByteArray(); + } + + @Override + public LatestTsKv deserialize(ObjectType type, byte[] bytes) throws Exception { + TransportProtos.LatestTsKvProto proto = TransportProtos.LatestTsKvProto.parseFrom(bytes); + EntityId entityId = EntityIdFactory.getByTypeAndUuid(ProtoUtils.fromProto(proto.getEntityType()), + new UUID(proto.getEntityIdMSB(), proto.getEntityIdLSB())); + DataPoint dataPoint = proto.hasDataPoint() ? fromDataPointProto(proto.getDataPoint()) : null; + return LatestTsKv.builder() + .entityId(entityId) + .key(proto.getKey()) + .version(proto.getVersion()) + .dataPoint(dataPoint) + .build(); + } + }); + } + + public static DataPointProto toDataPointProto(long ts, KvEntry kvEntry) { + DataPointProto.Builder proto = DataPointProto.newBuilder(); + proto.setTs(ts); + switch (kvEntry.getDataType()) { + case BOOLEAN -> proto.setBoolV(kvEntry.getBooleanValue().get()); + case LONG -> proto.setLongV(kvEntry.getLongValue().get()); + case DOUBLE -> proto.setDoubleV(kvEntry.getDoubleValue().get()); + case STRING -> { + String strValue = kvEntry.getStrValue().get(); + if (strValue.length() < CompressedStringDataPoint.MIN_STR_SIZE_TO_COMPRESS) { + proto.setStringV(strValue); + } else { + proto.setCompressedStringV(ByteString.copyFrom(compress(strValue))); + } + } + case JSON -> { + String jsonValue = kvEntry.getJsonValue().get(); + if (jsonValue.length() < CompressedStringDataPoint.MIN_STR_SIZE_TO_COMPRESS) { + proto.setJsonV(jsonValue); + } else { + proto.setCompressedJsonV(ByteString.copyFrom(compress(jsonValue))); + } + } + } + return proto.build(); + } + + public static DataPoint fromDataPointProto(DataPointProto proto) { + long ts = proto.getTs(); + if (proto.hasBoolV()) { + return new BoolDataPoint(ts, proto.getBoolV()); + } else if (proto.hasLongV()) { + return new LongDataPoint(ts, proto.getLongV()); + } else if (proto.hasDoubleV()) { + return new DoubleDataPoint(ts, proto.getDoubleV()); + } else if (proto.hasStringV()) { + return new StringDataPoint(ts, proto.getStringV()); + } else if (proto.hasCompressedStringV()) { + return new CompressedStringDataPoint(ts, proto.getCompressedStringV().toByteArray()); + } else if (proto.hasJsonV()) { + return new JsonDataPoint(ts, proto.getJsonV()); + } else if (proto.hasCompressedJsonV()) { + return new CompressedJsonDataPoint(ts, proto.getCompressedJsonV().toByteArray()); + } else { + throw new IllegalArgumentException("Unsupported data point proto: " + proto); + } + } + + @SneakyThrows + private static byte[] compress(String value) { + byte[] compressed = Snappy.compress(value); + // TODO: limit the size + log.debug("Compressed {} bytes to {} bytes", value.length(), compressed.length); + return compressed; + } + + public static Entity toEntity(EntityType entityType, Object entity) { + Entity edqsEntity = new Entity(); + edqsEntity.setType(entityType); + edqsEntity.setFields(FieldsUtil.toFields(entity)); + return edqsEntity; + } + + public EdqsObject check(ObjectType type, Object object) { + if (object instanceof EdqsObject edqsObject) { + return edqsObject; + } else { + return toEntity(type.toEntityType(), object); + } + } + + @SuppressWarnings("unchecked") + @SneakyThrows + public byte[] serialize(ObjectType type, T value) { + Converter converter = (Converter) converters.get(type); + if (converter != null) { + return converter.serialize(type, value); + } else { + return defaultConverter.serialize(type, (Entity) value); + } + } + + @SneakyThrows + public EdqsObject deserialize(ObjectType type, byte[] bytes) { + Converter converter = converters.get(type); + if (converter != null) { + return converter.deserialize(type, bytes); + } else { + return defaultConverter.deserialize(type, bytes); + } + } + + @RequiredArgsConstructor + private static class JsonConverter implements Converter { + + private static final ObjectMapper mapper = JsonMapper.builder() + .visibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) + .visibility(PropertyAccessor.GETTER, JsonAutoDetect.Visibility.NONE) + .visibility(PropertyAccessor.IS_GETTER, JsonAutoDetect.Visibility.NONE) + .build(); + + private final Class type; + + @SneakyThrows + @Override + public byte[] serialize(ObjectType objectType, T value) { + return mapper.writeValueAsBytes(value); + } + + @SneakyThrows + @Override + public T deserialize(ObjectType objectType, byte[] bytes) { + return mapper.readValue(bytes, this.type); + } + + } + + private interface Converter { + + byte[] serialize(ObjectType type, T value) throws Exception; + + T deserialize(ObjectType type, byte[] bytes) throws Exception; + + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/EdqsRocksDb.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/EdqsRocksDb.java new file mode 100644 index 0000000000..4a991432c7 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/EdqsRocksDb.java @@ -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. + */ +package org.thingsboard.server.edqs.util; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.Getter; +import org.rocksdb.Options; +import org.rocksdb.WriteOptions; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.thingsboard.server.queue.edqs.InMemoryEdqsComponent; + +import java.nio.file.Files; +import java.nio.file.Path; + +@Component +@InMemoryEdqsComponent +public class EdqsRocksDb extends TbRocksDb { + + @Getter + private boolean isNew; + + public EdqsRocksDb(@Value("${queue.edqs.local.rocksdb_path:${user.home}/.rocksdb/edqs}") String path) { + super(path, new Options().setCreateIfMissing(true), new WriteOptions()); + } + + @PostConstruct + @Override + public void init() { + isNew = !Files.exists(Path.of(path)); + super.init(); + } + + @PreDestroy + @Override + public void close() { + super.close(); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java new file mode 100644 index 0000000000..970f8585dd --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java @@ -0,0 +1,392 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.util; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.edqs.DataPoint; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.query.BooleanFilterPredicate; +import org.thingsboard.server.common.data.query.ComplexFilterPredicate; +import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityFilter; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityKeyValueType; +import org.thingsboard.server.common.data.query.EntityListFilter; +import org.thingsboard.server.common.data.query.EntityNameFilter; +import org.thingsboard.server.common.data.query.EntityTypeFilter; +import org.thingsboard.server.common.data.query.FilterPredicateType; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.KeyFilterPredicate; +import org.thingsboard.server.common.data.query.NumericFilterPredicate; +import org.thingsboard.server.common.data.query.RelationsQueryFilter; +import org.thingsboard.server.common.data.query.SingleEntityFilter; +import org.thingsboard.server.common.data.query.StringFilterPredicate; +import org.thingsboard.server.common.data.query.TsValue; +import org.thingsboard.server.common.data.util.CollectionsUtil; +import org.thingsboard.server.edqs.data.EntityData; +import org.thingsboard.server.edqs.query.DataKey; +import org.thingsboard.server.edqs.query.EdqsCountQuery; +import org.thingsboard.server.edqs.query.EdqsDataQuery; +import org.thingsboard.server.edqs.query.EdqsFilter; +import org.thingsboard.server.edqs.query.EdqsQuery; +import org.thingsboard.server.edqs.query.SortableEntityData; +import org.thingsboard.server.edqs.repo.KeyDictionary; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.apache.commons.lang3.StringUtils.containsIgnoreCase; +import static org.thingsboard.server.common.data.StringUtils.equalsAny; +import static org.thingsboard.server.common.data.StringUtils.splitByCommaWithoutQuotes; +import static org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation.AND; +import static org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation.OR; + +@Slf4j +public class RepositoryUtils { + + public static final Comparator SORT_ASC = Comparator.comparing((SortableEntityData sed) -> Optional.ofNullable(sed.getSortValue()).orElse("")) + .thenComparing(sp -> sp.getId().toString()); + + public static final Comparator SORT_DESC = Comparator.comparing((SortableEntityData sed) -> Optional.ofNullable(sed.getSortValue()).orElse("")) + .thenComparing(sp -> sp.getId().toString()).reversed(); + + public static EntityType resolveEntityType(EntityFilter entityFilter) { + return switch (entityFilter.getType()) { + case SINGLE_ENTITY -> ((SingleEntityFilter) entityFilter).getSingleEntity().getEntityType(); + case ENTITY_LIST -> ((EntityListFilter) entityFilter).getEntityType(); + case ENTITY_NAME -> ((EntityNameFilter) entityFilter).getEntityType(); + case ENTITY_TYPE -> ((EntityTypeFilter) entityFilter).getEntityType(); + case ASSET_TYPE, ASSET_SEARCH_QUERY -> EntityType.ASSET; + case DEVICE_TYPE, DEVICE_SEARCH_QUERY -> EntityType.DEVICE; + case ENTITY_VIEW_TYPE, ENTITY_VIEW_SEARCH_QUERY -> EntityType.ENTITY_VIEW; + case EDGE_TYPE, EDGE_SEARCH_QUERY -> EntityType.EDGE; + case RELATIONS_QUERY -> { + RelationsQueryFilter rgf = (RelationsQueryFilter) entityFilter; + yield rgf.isMultiRoot() ? rgf.getMultiRootEntitiesType() : rgf.getRootEntity().getEntityType(); + } + case API_USAGE_STATE -> EntityType.API_USAGE_STATE; + }; + } + + public static boolean customerUserIsTryingToAccessTenantEntity(QueryContext ctx, EntityFilter entityFilter) { + if (ctx.isTenantUser()) { + return false; + } else { + return switch (entityFilter.getType()) { + case SINGLE_ENTITY -> { + SingleEntityFilter seFilter = (SingleEntityFilter) entityFilter; + yield isSystemOrTenantEntity(seFilter.getSingleEntity().getEntityType()); + } + case ENTITY_LIST -> { + EntityListFilter elFilter = (EntityListFilter) entityFilter; + yield isSystemOrTenantEntity(elFilter.getEntityType()); + } + case ENTITY_NAME -> { + EntityNameFilter enFilter = (EntityNameFilter) entityFilter; + yield isSystemOrTenantEntity(enFilter.getEntityType()); + } + case ENTITY_TYPE -> { + EntityTypeFilter etFilter = (EntityTypeFilter) entityFilter; + yield isSystemOrTenantEntity(etFilter.getEntityType()); + } + default -> false; + }; + } + } + + private static boolean isSystemOrTenantEntity(EntityType entityType) { + return switch (entityType) { + case DEVICE_PROFILE, ASSET_PROFILE, RULE_CHAIN, TENANT, + TENANT_PROFILE, WIDGET_TYPE, WIDGETS_BUNDLE -> true; + default -> false; + }; + } + + public static EdqsDataQuery toNewQuery(EntityDataQuery oldQuery) { + var query = EdqsDataQuery.builder(); + query.page(oldQuery.getPageLink().getPage()); + query.pageSize(oldQuery.getPageLink().getPageSize()); + query.textSearch(oldQuery.getPageLink().getTextSearch()); + var sortOrder = oldQuery.getPageLink().getSortOrder(); + if (sortOrder != null && toNewKey(sortOrder.getKey()) != null) { + query.sortKey(toNewKey(sortOrder.getKey())); + query.sortDirection(sortOrder.getDirection()); + } else { + query.sortKey(new DataKey(EntityKeyType.ENTITY_FIELD, "createdTime", null)); + query.sortDirection(EntityDataSortOrder.Direction.DESC); + } + query.entityFilter(oldQuery.getEntityFilter()); + query.keyFilters(toKeyFilters(oldQuery.getKeyFilters())); + query.entityFields(toNewKeys(oldQuery.getEntityFields())); + query.latestValues(toNewKeys(oldQuery.getLatestValues())); + return query.build(); + } + + public static EdqsCountQuery toNewQuery(EntityCountQuery oldQuery) { + return EdqsCountQuery.builder() + .entityFilter(oldQuery.getEntityFilter()) + .hasKeyFilters(CollectionsUtil.isNotEmpty(oldQuery.getKeyFilters())) + .keyFilters(toKeyFilters(oldQuery.getKeyFilters())) + .build(); + } + + private static List toKeyFilters(List keyFilters) { + if (keyFilters == null || keyFilters.isEmpty()) { + return Collections.emptyList(); + } else { + List result = new ArrayList<>(); + for (KeyFilter entityFilter : keyFilters) { + var newKey = toNewKey(entityFilter.getKey()); + if (newKey != null) { + result.add(new EdqsFilter(newKey, entityFilter.getValueType(), entityFilter.getPredicate())); + } + } + return result; + } + } + + private static DataKey toNewKey(EntityKey entityKey) { + if (EntityKeyType.ENTITY_FIELD.equals(entityKey.getType())) { + return new DataKey(entityKey.getType(), entityKey.getKey(), null); + } + Integer keyId = KeyDictionary.get(entityKey.getKey()); + if (keyId != null) { + return new DataKey(entityKey.getType(), entityKey.getKey(), keyId); + } else { + log.warn("Missing dictionary key for {}", entityKey.getKey()); + return null; + } + } + + private static List toNewKeys(List entityKeys) { + if (entityKeys == null || entityKeys.isEmpty()) { + return Collections.emptyList(); + } else { + var result = new ArrayList(entityKeys.size()); + for (EntityKey entityKey : entityKeys) { + var newKey = toNewKey(entityKey); + if (newKey != null) { + result.add(newKey); + } + } + return result; + } + } + + public static boolean checkKeyFilters(EntityData entity, List keyFilters) { + for (EdqsFilter keyFilter : keyFilters) { + EntityKeyValueType valueType = keyFilter.valueType(); + if (valueType == null) { + valueType = switch (keyFilter.predicate().getType()) { + case STRING -> EntityKeyValueType.STRING; + case NUMERIC -> EntityKeyValueType.NUMERIC; + case BOOLEAN -> EntityKeyValueType.BOOLEAN; + default -> throw new IllegalStateException(); + }; + } + DataKey dataKey = keyFilter.key(); + DataPoint dp = entity.getDataPoint(dataKey, null); + boolean checkResult = switch (valueType) { + case STRING -> { + String str = dp != null ? dp.valueToString() : null; + yield (dataKey.type() == EntityKeyType.ENTITY_FIELD) ? (str == null || checkKeyFilter(str, keyFilter.predicate())) : + (str != null && checkKeyFilter(str, keyFilter.predicate())); + } + case BOOLEAN -> { + Boolean booleanValue = dp != null ? dp.getBool() : null; + yield booleanValue != null && checkKeyFilter(booleanValue, keyFilter.predicate()); + } + case DATE_TIME, NUMERIC -> { + Double doubleValue = dp != null ? dp.getDouble() : null; + yield doubleValue != null && checkKeyFilter(doubleValue, keyFilter.predicate()); + } + }; + if (!checkResult) { + return false; + } + } + return true; + } + + public static boolean checkKeyFilter(String value, KeyFilterPredicate keyFilterPredicate) { + if (keyFilterPredicate.getType() == FilterPredicateType.COMPLEX) { + return checkComplexKeyFilter(value, (ComplexFilterPredicate) keyFilterPredicate, RepositoryUtils::checkKeyFilter); + } + if (keyFilterPredicate.getType() != FilterPredicateType.STRING) { + throw new IllegalStateException("Not implemented"); + } + StringFilterPredicate predicate = (StringFilterPredicate) keyFilterPredicate; + String predicateValue = predicate.getValue().getValue(); + if (StringUtils.isEmpty(predicateValue)) { + return true; + } + if (predicate.isIgnoreCase()) { + predicateValue = predicateValue.toLowerCase(); + value = value.toLowerCase(); + } + return switch (predicate.getOperation()) { + case EQUAL -> value.equals(predicateValue); + case STARTS_WITH -> value.startsWith(predicateValue); + case ENDS_WITH -> value.endsWith(predicateValue); + case NOT_EQUAL -> !value.equals(predicateValue); + case CONTAINS -> value.contains(predicateValue); + case NOT_CONTAINS -> !value.contains(predicateValue); + case IN -> equalsAny(value, splitByCommaWithoutQuotes(predicateValue)); + case NOT_IN -> !equalsAny(value, splitByCommaWithoutQuotes(predicateValue)); + }; + } + + public static boolean checkKeyFilter(Double value, KeyFilterPredicate keyFilterPredicate) { + if (keyFilterPredicate.getType() == FilterPredicateType.COMPLEX) { + return checkComplexKeyFilter(value, (ComplexFilterPredicate) keyFilterPredicate, RepositoryUtils::checkKeyFilter); + } + if (keyFilterPredicate.getType() != FilterPredicateType.NUMERIC) { + throw new IllegalStateException("Not implemented"); + } + NumericFilterPredicate predicate = (NumericFilterPredicate) keyFilterPredicate; + Double predicateValue = predicate.getValue().getValue(); + return switch (predicate.getOperation()) { + case EQUAL -> value.equals(predicateValue); + case NOT_EQUAL -> !value.equals(predicateValue); + case GREATER -> value.compareTo(predicateValue) > 0; + case LESS -> value.compareTo(predicateValue) < 0; + case GREATER_OR_EQUAL -> value.compareTo(predicateValue) >= 0; + case LESS_OR_EQUAL -> value.compareTo(predicateValue) <= 0; + }; + } + + public static boolean checkKeyFilter(Boolean value, KeyFilterPredicate keyFilterPredicate) { + if (keyFilterPredicate.getType() == FilterPredicateType.COMPLEX) { + return checkComplexKeyFilter(value, (ComplexFilterPredicate) keyFilterPredicate, RepositoryUtils::checkKeyFilter); + } + if (keyFilterPredicate.getType() != FilterPredicateType.BOOLEAN) { + throw new IllegalStateException("Not implemented"); + } + BooleanFilterPredicate predicate = (BooleanFilterPredicate) keyFilterPredicate; + Boolean predicateValue = predicate.getValue().getValue(); + return switch (predicate.getOperation()) { + case EQUAL -> value.equals(predicateValue); + case NOT_EQUAL -> !value.equals(predicateValue); + }; + } + + public static boolean checkComplexKeyFilter(T value, ComplexFilterPredicate filterPredicates, + SimpleKeyFilter simpleKeyFilter) { + if (filterPredicates.getOperation() == AND) { + for (KeyFilterPredicate filterPredicate : filterPredicates.getPredicates()) { + if (!simpleKeyFilter.check(value, filterPredicate)) { + return false; + } + } + return true; + } else if (filterPredicates.getOperation() == OR) { + for (KeyFilterPredicate filterPredicate : filterPredicates.getPredicates()) { + if (simpleKeyFilter.check(value, filterPredicate)) { + return true; + } + } + return false; + } else { + return false; + } + } + + public static Pattern toSqlLikePattern(String nameFilter) { + if (StringUtils.isNotBlank(nameFilter)) { + boolean percentSymbolOnStart = nameFilter.startsWith("%"); + boolean percentSymbolOnEnd = nameFilter.endsWith("%"); + if (percentSymbolOnStart) { + nameFilter = nameFilter.substring(1); + } + if (percentSymbolOnEnd) { + nameFilter = nameFilter.substring(0, nameFilter.length() - 1); + } + if (percentSymbolOnStart || percentSymbolOnEnd) { + return Pattern.compile((percentSymbolOnStart ? ".*" : "") + Pattern.quote(nameFilter) + (percentSymbolOnEnd ? ".*" : ""), Pattern.CASE_INSENSITIVE); + } else { + return Pattern.compile(Pattern.quote(nameFilter) + ".*", Pattern.CASE_INSENSITIVE); + } + } + return null; + } + + @FunctionalInterface + public interface SimpleKeyFilter { + + boolean check(T value, KeyFilterPredicate predicate); + + } + + public static TsValue toTsValue(long ts, DataPoint dp) { + if (dp != null) { + return new TsValue(dp.getTs() > 0 ? dp.getTs() : ts, dp.valueToString()); + } else { + return new TsValue(ts, ""); + } + } + + public static String getSortValue(EntityData entity, DataKey sortKey) { + if (sortKey == null) { + return null; + } + switch (sortKey.type()) { + case ENTITY_FIELD -> { + return entity.getField(sortKey.key()); + } + case ATTRIBUTE, CLIENT_ATTRIBUTE, SHARED_ATTRIBUTE, SERVER_ATTRIBUTE -> { + var dp = entity.getAttr(sortKey.keyId(), sortKey.type()); + return dp != null ? dp.valueToString() : ""; + } + case TIME_SERIES -> { + var dp = entity.getTs(sortKey.keyId()); + return dp != null ? dp.valueToString() : ""; + } + default -> throw new IllegalStateException("toSortKey is not implemented for type: " + sortKey.type()); + } + } + + public static boolean checkFilters(EdqsQuery query, EntityData entity) { + if (entity == null || entity.getFields() == null) { + return false; // Entity was already removed or not arrived yet; + } + if (query.isHasKeyFilters() && !checkKeyFilters(entity, query.getKeyFilters())) { + return false; + } + if (query instanceof EdqsDataQuery dataQuery) { + return !dataQuery.isHasTextSearch() || checkTextSearch(entity, dataQuery); + } + return true; + } + + private static boolean checkTextSearch(EntityData entityData, EdqsDataQuery query) { + return Stream.concat(query.getEntityFields().stream(), query.getLatestValues().stream()) + .anyMatch(key -> { + DataPoint value = entityData.getDataPoint(key, null); + return value != null && containsIgnoreCase(value.valueToString(), query.getTextSearch()); + }); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/TbBytePool.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/TbBytePool.java new file mode 100644 index 0000000000..3b135be59c --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/TbBytePool.java @@ -0,0 +1,39 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.util; + +import com.google.common.hash.Hashing; +import org.springframework.util.ConcurrentReferenceHashMap; + +import java.util.concurrent.ConcurrentMap; + +public class TbBytePool { + + private static final ConcurrentMap pool = new ConcurrentReferenceHashMap<>(); + + public static byte[] intern(byte[] data) { + if (data == null) { + return null; + } + var checksum = Hashing.sha512().hashBytes(data).toString(); + return pool.computeIfAbsent(checksum, c -> data); + } + + public static int size(){ + return pool.size(); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/TbRocksDb.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/TbRocksDb.java new file mode 100644 index 0000000000..23f2fa2c9e --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/TbRocksDb.java @@ -0,0 +1,77 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.util; + +import lombok.SneakyThrows; +import org.rocksdb.Options; +import org.rocksdb.RocksDB; +import org.rocksdb.RocksIterator; +import org.rocksdb.WriteOptions; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.function.BiConsumer; + +public class TbRocksDb { + + protected final String path; + private final Options dbOptions; + private final WriteOptions writeOptions; + protected RocksDB db; + + static { + RocksDB.loadLibrary(); + } + + public TbRocksDb(String path, Options dbOptions, WriteOptions writeOptions) { + this.path = path; + this.dbOptions = dbOptions; + this.writeOptions = writeOptions; + } + + @SneakyThrows + public void init() { + Files.createDirectories(Path.of(path).getParent()); + db = RocksDB.open(dbOptions, path); + } + + @SneakyThrows + public void put(String key, byte[] value) { + db.put(writeOptions, key.getBytes(StandardCharsets.UTF_8), value); + } + + public void forEach(BiConsumer processor) { + try (RocksIterator iterator = db.newIterator()) { + for (iterator.seekToFirst(); iterator.isValid(); iterator.next()) { + String key = new String(iterator.key(), StandardCharsets.UTF_8); + processor.accept(key, iterator.value()); + } + } + } + + @SneakyThrows + public void delete(String key) { + db.delete(writeOptions, key.getBytes(StandardCharsets.UTF_8)); + } + + public void close() { + if (db != null) { + db.close(); + } + } + +} diff --git a/common/actor/src/main/java/org/thingsboard/server/actors/JsInvokeStats.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/TbStringPool.java similarity index 56% rename from common/actor/src/main/java/org/thingsboard/server/actors/JsInvokeStats.java rename to common/edqs/src/main/java/org/thingsboard/server/edqs/util/TbStringPool.java index bf81e04b71..9c9c3b5b13 100644 --- a/common/actor/src/main/java/org/thingsboard/server/actors/JsInvokeStats.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/TbStringPool.java @@ -13,32 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.actors; +package org.thingsboard.server.edqs.util; -public interface JsInvokeStats { - default void incrementRequests() { - incrementRequests(1); - } +import org.springframework.util.ConcurrentReferenceHashMap; - void incrementRequests(int amount); +import java.util.concurrent.ConcurrentMap; - default void incrementResponses() { - incrementResponses(1); - } +public class TbStringPool { - void incrementResponses(int amount); + private static final ConcurrentMap pool = new ConcurrentReferenceHashMap<>(); - default void incrementFailures() { - incrementFailures(1); + public static String intern(String data) { + if (data == null) { + return null; + } + return pool.computeIfAbsent(data, str -> str); } - void incrementFailures(int amount); - - int getRequests(); - - int getResponses(); - - int getFailures(); + public static int size(){ + return pool.size(); + } - void reset(); } diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/VersionsStore.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/VersionsStore.java new file mode 100644 index 0000000000..e52b1bbac9 --- /dev/null +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/VersionsStore.java @@ -0,0 +1,48 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.util; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +@Slf4j +public class VersionsStore { + + private final Cache versions = Caffeine.newBuilder() + .expireAfterWrite(1, TimeUnit.HOURS) + .build(); + + public boolean isNew(String key, Long version) { + AtomicBoolean isNew = new AtomicBoolean(false); + versions.asMap().compute(key, (k, prevVersion) -> { + if (prevVersion == null || prevVersion < version) { + isNew.set(true); + return version; + } else { + if (version < prevVersion) { + log.info("[{}] Version {} is outdated, the latest is {}", key, version, prevVersion); + } + return prevVersion; + } + }); + return isNew.get(); + } + +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java b/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java index d5a6ff99b2..178caf7961 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java @@ -133,7 +133,22 @@ public enum MsgType { * Messages that are sent to and from edge session to start edge synchronization process */ EDGE_SYNC_REQUEST_TO_EDGE_SESSION_MSG, - EDGE_SYNC_RESPONSE_FROM_EDGE_SESSION_MSG; + EDGE_SYNC_RESPONSE_FROM_EDGE_SESSION_MSG, + + + CF_INIT_MSG, // Sent to init particular calculated field; + CF_LINK_INIT_MSG, // Sent to init particular calculated field; + CF_STATE_RESTORE_MSG, // Sent to restore particular calculated field entity state; + CF_PARTITIONS_CHANGE_MSG, // Sent when cluster event occures; + + CF_ENTITY_LIFECYCLE_MSG, // Sent on CF/Device/Asset create/update/delete; + CF_TELEMETRY_MSG, // Sent from queue to actor system; + CF_LINKED_TELEMETRY_MSG, // Sent from queue to actor system; + + /* CF Manager Actor -> CF Entity actor */ + CF_ENTITY_TELEMETRY_MSG, + CF_ENTITY_INIT_CF_MSG, + CF_ENTITY_DELETE_MSG; @Getter private final boolean ignoreOnStart; diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java index 5592244bd9..4e0f583285 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java @@ -24,6 +24,7 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; @@ -34,8 +35,10 @@ import org.thingsboard.server.common.msg.gen.MsgProtos; import org.thingsboard.server.common.msg.queue.TbMsgCallback; import java.io.Serializable; +import java.util.List; import java.util.Objects; import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; /** * Created by ashvayka on 13.01.18. @@ -64,6 +67,8 @@ public final class TbMsg implements Serializable { private final UUID correlationId; private final Integer partition; + private final List previousCalculatedFieldIds; + @Getter(value = AccessLevel.NONE) @JsonIgnore //This field is not serialized because we use queues and there is no need to do it @@ -112,7 +117,7 @@ public final class TbMsg implements Serializable { } private TbMsg(String queueName, UUID id, long ts, TbMsgType internalType, String type, EntityId originator, CustomerId customerId, TbMsgMetaData metaData, TbMsgDataType dataType, String data, - RuleChainId ruleChainId, RuleNodeId ruleNodeId, UUID correlationId, Integer partition, TbMsgProcessingCtx ctx, TbMsgCallback callback) { + RuleChainId ruleChainId, RuleNodeId ruleNodeId, UUID correlationId, Integer partition, List previousCalculatedFieldIds, TbMsgProcessingCtx ctx, TbMsgCallback callback) { this.id = id != null ? id : UUID.randomUUID(); this.queueName = queueName; if (ts > 0) { @@ -139,6 +144,9 @@ public final class TbMsg implements Serializable { this.ruleNodeId = ruleNodeId; this.correlationId = correlationId; this.partition = partition; + this.previousCalculatedFieldIds = previousCalculatedFieldIds != null + ? new CopyOnWriteArrayList<>(previousCalculatedFieldIds) + : new CopyOnWriteArrayList<>(); this.ctx = ctx != null ? ctx : new TbMsgProcessingCtx(); this.callback = Objects.requireNonNullElse(callback, TbMsgCallback.EMPTY); } @@ -186,6 +194,16 @@ public final class TbMsg implements Serializable { builder.setPartition(msg.getPartition()); } + if (msg.getPreviousCalculatedFieldIds() != null) { + for (CalculatedFieldId calculatedFieldId : msg.getPreviousCalculatedFieldIds()) { + MsgProtos.CalculatedFieldIdProto calculatedFieldIdProto = MsgProtos.CalculatedFieldIdProto.newBuilder() + .setCalculatedFieldIdMSB(calculatedFieldId.getId().getMostSignificantBits()) + .setCalculatedFieldIdLSB(calculatedFieldId.getId().getLeastSignificantBits()) + .build(); + builder.addCalculatedFields(calculatedFieldIdProto); + } + } + builder.setCtx(msg.ctx.toProto()); return builder.build().toByteArray(); } @@ -200,6 +218,7 @@ public final class TbMsg implements Serializable { RuleNodeId ruleNodeId = null; UUID correlationId = null; Integer partition = null; + List calculatedFieldIds = new CopyOnWriteArrayList<>(); if (proto.getCustomerIdMSB() != 0L && proto.getCustomerIdLSB() != 0L) { customerId = new CustomerId(new UUID(proto.getCustomerIdMSB(), proto.getCustomerIdLSB())); } @@ -214,6 +233,14 @@ public final class TbMsg implements Serializable { partition = proto.getPartition(); } + for (MsgProtos.CalculatedFieldIdProto cfIdProto : proto.getCalculatedFieldsList()) { + CalculatedFieldId calculatedFieldId = new CalculatedFieldId(new UUID( + cfIdProto.getCalculatedFieldIdMSB(), + cfIdProto.getCalculatedFieldIdLSB() + )); + calculatedFieldIds.add(calculatedFieldId); + } + TbMsgProcessingCtx ctx; if (proto.hasCtx()) { ctx = TbMsgProcessingCtx.fromProto(proto.getCtx()); @@ -224,7 +251,7 @@ public final class TbMsg implements Serializable { TbMsgDataType dataType = TbMsgDataType.values()[proto.getDataType()]; return new TbMsg(queueName, UUID.fromString(proto.getId()), proto.getTs(), null, proto.getType(), entityId, customerId, - metaData, dataType, proto.getData(), ruleChainId, ruleNodeId, correlationId, partition, ctx, callback); + metaData, dataType, proto.getData(), ruleChainId, ruleNodeId, correlationId, partition, calculatedFieldIds, ctx, callback); } catch (InvalidProtocolBufferException e) { throw new IllegalStateException("Could not parse protobuf for TbMsg", e); } @@ -249,6 +276,7 @@ public final class TbMsg implements Serializable { /** * Checks if the message is still valid for processing. May be invalid if the message pack is timed-out or canceled. + * * @return 'true' if message is valid for processing, 'false' otherwise. */ public boolean isValid() { @@ -343,6 +371,7 @@ public final class TbMsg implements Serializable { protected RuleNodeId ruleNodeId; protected UUID correlationId; protected Integer partition; + protected List previousCalculatedFieldIds; protected TbMsgProcessingCtx ctx; protected TbMsgCallback callback; @@ -363,6 +392,7 @@ public final class TbMsg implements Serializable { this.ruleNodeId = tbMsg.ruleNodeId; this.correlationId = tbMsg.correlationId; this.partition = tbMsg.partition; + this.previousCalculatedFieldIds = tbMsg.previousCalculatedFieldIds; this.ctx = tbMsg.ctx; this.callback = tbMsg.callback; } @@ -385,8 +415,7 @@ public final class TbMsg implements Serializable { /** *

Deprecated: This should only be used when you need to specify a custom message type that doesn't exist in the {@link TbMsgType} enum. * Prefer using {@link #type(TbMsgType)} instead. - * - * */ + */ @Deprecated public TbMsgBuilder type(String type) { this.type = type; @@ -454,6 +483,11 @@ public final class TbMsg implements Serializable { return this; } + public TbMsgBuilder previousCalculatedFieldIds(List previousCalculatedFieldIds) { + this.previousCalculatedFieldIds = new CopyOnWriteArrayList<>(previousCalculatedFieldIds); + return this; + } + public TbMsgBuilder ctx(TbMsgProcessingCtx ctx) { this.ctx = ctx; return this; @@ -465,7 +499,7 @@ public final class TbMsg implements Serializable { } public TbMsg build() { - return new TbMsg(queueName, id, ts, internalType, type, originator, customerId, metaData, dataType, data, ruleChainId, ruleNodeId, correlationId, partition, ctx, callback); + return new TbMsg(queueName, id, ts, internalType, type, originator, customerId, metaData, dataType, data, ruleChainId, ruleNodeId, correlationId, partition, previousCalculatedFieldIds, ctx, callback); } public String toString() { @@ -473,8 +507,8 @@ public final class TbMsg implements Serializable { ", type=" + this.type + ", internalType=" + this.internalType + ", originator=" + this.originator + ", customerId=" + this.customerId + ", metaData=" + this.metaData + ", dataType=" + this.dataType + ", data=" + this.data + ", ruleChainId=" + this.ruleChainId + ", ruleNodeId=" + this.ruleNodeId + - ", correlationId=" + this.correlationId + ", partition=" + this.partition + ", ctx=" + this.ctx + - ", callback=" + this.callback + ")"; + ", correlationId=" + this.correlationId + ", partition=" + this.partition + ", previousCalculatedFields=" + this.previousCalculatedFieldIds + + ", ctx=" + this.ctx + ", callback=" + this.callback + ")"; } } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/ToCalculatedFieldSystemMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/ToCalculatedFieldSystemMsg.java new file mode 100644 index 0000000000..c05c0f121e --- /dev/null +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/ToCalculatedFieldSystemMsg.java @@ -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. + */ +package org.thingsboard.server.common.msg; + +import org.thingsboard.server.common.msg.aware.TenantAwareMsg; +import org.thingsboard.server.common.msg.queue.TbCallback; + +public interface ToCalculatedFieldSystemMsg extends TenantAwareMsg { + + default TbCallback getCallback() { + return TbCallback.EMPTY; + } + +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/cf/CalculatedFieldEntityLifecycleMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/cf/CalculatedFieldEntityLifecycleMsg.java new file mode 100644 index 0000000000..1eb8f425ab --- /dev/null +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/cf/CalculatedFieldEntityLifecycleMsg.java @@ -0,0 +1,34 @@ +/** + * 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. + */ +package org.thingsboard.server.common.msg.cf; + +import lombok.Data; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; + +@Data +public class CalculatedFieldEntityLifecycleMsg implements ToCalculatedFieldSystemMsg { + + private final TenantId tenantId; + private final ComponentLifecycleMsg data; + + @Override + public MsgType getMsgType() { + return MsgType.CF_ENTITY_LIFECYCLE_MSG; + } +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/cf/CalculatedFieldInitMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/cf/CalculatedFieldInitMsg.java new file mode 100644 index 0000000000..e453d2963c --- /dev/null +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/cf/CalculatedFieldInitMsg.java @@ -0,0 +1,34 @@ +/** + * 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. + */ +package org.thingsboard.server.common.msg.cf; + +import lombok.Data; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; + +@Data +public class CalculatedFieldInitMsg implements ToCalculatedFieldSystemMsg { + + private final TenantId tenantId; + private final CalculatedField cf; + + @Override + public MsgType getMsgType() { + return MsgType.CF_INIT_MSG; + } +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/cf/CalculatedFieldLinkInitMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/cf/CalculatedFieldLinkInitMsg.java new file mode 100644 index 0000000000..d142eb78d8 --- /dev/null +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/cf/CalculatedFieldLinkInitMsg.java @@ -0,0 +1,34 @@ +/** + * 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. + */ +package org.thingsboard.server.common.msg.cf; + +import lombok.Data; +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; + +@Data +public class CalculatedFieldLinkInitMsg implements ToCalculatedFieldSystemMsg { + + private final TenantId tenantId; + private final CalculatedFieldLink link; + + @Override + public MsgType getMsgType() { + return MsgType.CF_LINK_INIT_MSG; + } +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/cf/CalculatedFieldPartitionChangeMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/cf/CalculatedFieldPartitionChangeMsg.java new file mode 100644 index 0000000000..44756013ca --- /dev/null +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/cf/CalculatedFieldPartitionChangeMsg.java @@ -0,0 +1,38 @@ +/** + * 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. + */ +package org.thingsboard.server.common.msg.cf; + +import lombok.Data; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; + +import java.util.Set; + +@Data +public class CalculatedFieldPartitionChangeMsg implements ToCalculatedFieldSystemMsg { + + @Override + public TenantId getTenantId() { + return TenantId.SYS_TENANT_ID; + } + + @Override + public MsgType getMsgType() { + return MsgType.CF_PARTITIONS_CHANGE_MSG; + } +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/edqs/EdqsApiService.java b/common/message/src/main/java/org/thingsboard/server/common/msg/edqs/EdqsApiService.java new file mode 100644 index 0000000000..05864fe863 --- /dev/null +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/edqs/EdqsApiService.java @@ -0,0 +1,36 @@ +/** + * 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. + */ +package org.thingsboard.server.common.msg.edqs; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.edqs.query.EdqsRequest; +import org.thingsboard.server.common.data.edqs.query.EdqsResponse; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; + +public interface EdqsApiService { + + ListenableFuture processRequest(TenantId tenantId, CustomerId customerId, EdqsRequest request); + + boolean isEnabled(); + + void setEnabled(boolean enabled); + + boolean isSupported(); + + boolean isAutoEnable(); + +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/edqs/EdqsService.java b/common/message/src/main/java/org/thingsboard/server/common/msg/edqs/EdqsService.java new file mode 100644 index 0000000000..32ff57e3e0 --- /dev/null +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/edqs/EdqsService.java @@ -0,0 +1,39 @@ +/** + * 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. + */ +package org.thingsboard.server.common.msg.edqs; + +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.edqs.EdqsObject; +import org.thingsboard.server.common.data.edqs.ToCoreEdqsMsg; +import org.thingsboard.server.common.data.edqs.ToCoreEdqsRequest; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; + +public interface EdqsService { + + void onUpdate(TenantId tenantId, EntityId entityId, Object entity); + + void onUpdate(TenantId tenantId, ObjectType objectType, EdqsObject object); + + void onDelete(TenantId tenantId, EntityId entityId); + + void onDelete(TenantId tenantId, ObjectType objectType, EdqsObject object); + + void processSystemRequest(ToCoreEdqsRequest request); + + void processSystemMsg(ToCoreEdqsMsg request); + +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java index b429d503d9..13fd6159fc 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.msg.plugin; +import lombok.Builder; import lombok.Data; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.EntityId; @@ -25,6 +26,7 @@ import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.aware.TenantAwareMsg; import org.thingsboard.server.common.msg.cluster.ToAllNodesMsg; +import java.io.Serial; import java.util.Optional; /** @@ -32,11 +34,32 @@ import java.util.Optional; */ @Data public class ComponentLifecycleMsg implements TenantAwareMsg, ToAllNodesMsg { + + @Serial private static final long serialVersionUID = -5303421482781273062L; private final TenantId tenantId; private final EntityId entityId; private final ComponentLifecycleEvent event; + private final String oldName; + private final String name; + private final EntityId oldProfileId; + private final EntityId profileId; + + public ComponentLifecycleMsg(TenantId tenantId, EntityId entityId, ComponentLifecycleEvent event) { + this(tenantId, entityId, event, null, null, null, null); + } + + @Builder + private ComponentLifecycleMsg(TenantId tenantId, EntityId entityId, ComponentLifecycleEvent event, String oldName, String name, EntityId oldProfileId, EntityId profileId) { + this.tenantId = tenantId; + this.entityId = entityId; + this.event = event; + this.oldName = oldName; + this.name = name; + this.oldProfileId = oldProfileId; + this.profileId = profileId; + } public Optional getRuleChainId() { return entityId.getEntityType() == EntityType.RULE_CHAIN ? Optional.of((RuleChainId) entityId) : Optional.empty(); @@ -46,4 +69,5 @@ public class ComponentLifecycleMsg implements TenantAwareMsg, ToAllNodesMsg { public MsgType getMsgType() { return MsgType.COMPONENT_LIFE_CYCLE_MSG; } + } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/queue/ServiceType.java b/common/message/src/main/java/org/thingsboard/server/common/msg/queue/ServiceType.java index 3547ea9120..f31fdfa7a8 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/queue/ServiceType.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/queue/ServiceType.java @@ -26,7 +26,8 @@ public enum ServiceType { TB_RULE_ENGINE("TB Rule Engine"), TB_TRANSPORT("TB Transport"), JS_EXECUTOR("JS Executor"), - TB_VC_EXECUTOR("TB VC Executor"); + TB_VC_EXECUTOR("TB VC Executor"), + EDQS("TB Entity Data Query Service"); private final String label; diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/queue/TbCallback.java b/common/message/src/main/java/org/thingsboard/server/common/msg/queue/TbCallback.java index c8eed097bf..ee8990d931 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/queue/TbCallback.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/queue/TbCallback.java @@ -15,6 +15,10 @@ */ package org.thingsboard.server.common.msg.queue; +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.UUID; + public interface TbCallback { TbCallback EMPTY = new TbCallback() { @@ -30,6 +34,10 @@ public interface TbCallback { } }; + default UUID getId(){ + return EntityId.NULL_UUID; + } + void onSuccess(); void onFailure(Throwable t); diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/queue/TopicPartitionInfo.java b/common/message/src/main/java/org/thingsboard/server/common/msg/queue/TopicPartitionInfo.java index ddfbd36a33..f09826e9b6 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/queue/TopicPartitionInfo.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/queue/TopicPartitionInfo.java @@ -17,41 +17,48 @@ package org.thingsboard.server.common.msg.queue; import lombok.Builder; import lombok.Getter; -import lombok.ToString; import org.thingsboard.server.common.data.id.TenantId; import java.util.Objects; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; -@ToString public class TopicPartitionInfo { private final String topic; private final TenantId tenantId; private final Integer partition; @Getter + private final boolean useInternalPartition; + @Getter private final String fullTopicName; @Getter private final boolean myPartition; @Builder - public TopicPartitionInfo(String topic, TenantId tenantId, Integer partition, boolean myPartition) { + public TopicPartitionInfo(String topic, TenantId tenantId, Integer partition, boolean useInternalPartition, boolean myPartition) { this.topic = topic; this.tenantId = tenantId; this.partition = partition; + this.useInternalPartition = useInternalPartition; this.myPartition = myPartition; String tmp = topic; if (tenantId != null && !tenantId.isNullUid()) { tmp += ".isolated." + tenantId.getId().toString(); } - if (partition != null) { + if (partition != null && !useInternalPartition) { tmp += "." + partition; } this.fullTopicName = tmp; } + public TopicPartitionInfo(String topic, TenantId tenantId, Integer partition, boolean myPartition) { + this(topic, tenantId, partition, false, myPartition); + } + public TopicPartitionInfo newByTopic(String topic) { - return new TopicPartitionInfo(topic, this.tenantId, this.partition, this.myPartition); + return new TopicPartitionInfo(topic, this.tenantId, this.partition, this.useInternalPartition, this.myPartition); } public String getTopic() { @@ -66,6 +73,18 @@ public class TopicPartitionInfo { return Optional.ofNullable(partition); } + public TopicPartitionInfo withTopic(String topic) { + return new TopicPartitionInfo(topic, this.tenantId, this.partition, this.useInternalPartition, this.myPartition); + } + + public static Set withTopic(Set partitions, String topic) { + return partitions.stream().map(tpi -> tpi.withTopic(topic)).collect(Collectors.toSet()); + } + + public TopicPartitionInfo withUseInternalPartition(boolean useInternalPartition) { + return new TopicPartitionInfo(this.topic, this.tenantId, this.partition, useInternalPartition, this.myPartition); + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -79,6 +98,16 @@ public class TopicPartitionInfo { @Override public int hashCode() { - return Objects.hash(fullTopicName); + return Objects.hash(fullTopicName, partition); + } + + @Override + public String toString() { + String str = fullTopicName; + if (useInternalPartition) { + str += "[" + partition + "]"; + } + return str; } + } diff --git a/common/message/src/main/proto/tbmsg.proto b/common/message/src/main/proto/tbmsg.proto index 86bdef30e7..65a967e9e4 100644 --- a/common/message/src/main/proto/tbmsg.proto +++ b/common/message/src/main/proto/tbmsg.proto @@ -70,4 +70,11 @@ message TbMsgProto { int64 correlationIdMSB = 20; int64 correlationIdLSB = 21; int32 partition = 22; + + repeated CalculatedFieldIdProto calculatedFields = 23; +} + +message CalculatedFieldIdProto { + int64 calculatedFieldIdMSB = 1; + int64 calculatedFieldIdLSB = 2; } diff --git a/common/pom.xml b/common/pom.xml index c275227018..9a7a836ba5 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -49,6 +49,7 @@ edge-api version-control script + edqs diff --git a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java index be210ed4ed..1ebd753f3c 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.google.protobuf.ByteString; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.ApiUsageStateValue; import org.thingsboard.server.common.data.Device; @@ -31,6 +32,7 @@ import org.thingsboard.server.common.data.EdgeUtils; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; @@ -58,12 +60,15 @@ import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.kv.AttributeKey; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.BasicKvEntry; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.BooleanDataEntry; import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.JsonDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.data.rpc.RpcError; import org.thingsboard.server.common.data.rpc.ToDeviceRpcRequestBody; @@ -88,6 +93,8 @@ import org.thingsboard.server.common.msg.rule.engine.DeviceDeleteMsg; import org.thingsboard.server.common.msg.rule.engine.DeviceEdgeUpdateMsg; import org.thingsboard.server.common.msg.rule.engine.DeviceNameOrTypeUpdateMsg; import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.KeyValueProto; +import org.thingsboard.server.gen.transport.TransportProtos.ApiUsageRecordKeyProto; import java.util.ArrayList; import java.util.Arrays; @@ -111,14 +118,28 @@ public class ProtoUtils { } public static TransportProtos.ComponentLifecycleMsgProto toProto(ComponentLifecycleMsg msg) { - return TransportProtos.ComponentLifecycleMsgProto.newBuilder() + var builder = TransportProtos.ComponentLifecycleMsgProto.newBuilder() .setTenantIdMSB(msg.getTenantId().getId().getMostSignificantBits()) .setTenantIdLSB(msg.getTenantId().getId().getLeastSignificantBits()) .setEntityType(toProto(msg.getEntityId().getEntityType())) .setEntityIdMSB(msg.getEntityId().getId().getMostSignificantBits()) .setEntityIdLSB(msg.getEntityId().getId().getLeastSignificantBits()) - .setEvent(TransportProtos.ComponentLifecycleEvent.forNumber(msg.getEvent().ordinal())) - .build(); + .setEvent(TransportProtos.ComponentLifecycleEvent.forNumber(msg.getEvent().ordinal())); + if (msg.getProfileId() != null) { + builder.setProfileIdMSB(msg.getProfileId().getId().getMostSignificantBits()); + builder.setProfileIdLSB(msg.getProfileId().getId().getLeastSignificantBits()); + } + if (msg.getOldProfileId() != null) { + builder.setOldProfileIdMSB(msg.getOldProfileId().getId().getMostSignificantBits()); + builder.setOldProfileIdLSB(msg.getOldProfileId().getId().getLeastSignificantBits()); + } + if (msg.getName() != null) { + builder.setName(msg.getName()); + } + if (msg.getOldName() != null) { + builder.setOldName(msg.getOldName()); + } + return builder.build(); } public static TransportProtos.EntityTypeProto toProto(EntityType entityType) { @@ -126,18 +147,32 @@ public class ProtoUtils { } public static ComponentLifecycleMsg fromProto(TransportProtos.ComponentLifecycleMsgProto proto) { - return new ComponentLifecycleMsg( - TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), - EntityIdFactory.getByTypeAndUuid(fromProto(proto.getEntityType()), new UUID(proto.getEntityIdMSB(), proto.getEntityIdLSB())), - ComponentLifecycleEvent.values()[proto.getEventValue()] - ); + EntityId entityId = EntityIdFactory.getByTypeAndUuid(fromProto(proto.getEntityType()), new UUID(proto.getEntityIdMSB(), proto.getEntityIdLSB())); + var builder = ComponentLifecycleMsg.builder() + .tenantId(TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB()))) + .entityId(entityId) + .event(ComponentLifecycleEvent.values()[proto.getEventValue()]); + if (!StringUtils.isEmpty(proto.getName())) { + builder.name(proto.getName()); + } + if (!StringUtils.isEmpty(proto.getOldName())) { + builder.oldName(proto.getOldName()); + } + if (proto.getProfileIdMSB() != 0 || proto.getProfileIdLSB() != 0) { + var profileType = EntityType.DEVICE.equals(entityId.getEntityType()) ? EntityType.DEVICE_PROFILE : EntityType.ASSET_PROFILE; + builder.profileId(EntityIdFactory.getByTypeAndUuid(profileType, new UUID(proto.getProfileIdMSB(), proto.getProfileIdLSB()))); + } + if (proto.getOldProfileIdMSB() != 0 || proto.getOldProfileIdLSB() != 0) { + var profileType = EntityType.DEVICE.equals(entityId.getEntityType()) ? EntityType.DEVICE_PROFILE : EntityType.ASSET_PROFILE; + builder.oldProfileId(EntityIdFactory.getByTypeAndUuid(profileType, new UUID(proto.getOldProfileIdMSB(), proto.getOldProfileIdLSB()))); + } + return builder.build(); } public static EntityType fromProto(TransportProtos.EntityTypeProto entityType) { return entityTypeByProtoNumber[entityType.getNumber()]; } - public static TransportProtos.ToEdgeSyncRequestMsgProto toProto(ToEdgeSyncRequest request) { return TransportProtos.ToEdgeSyncRequestMsgProto.newBuilder() .setTenantIdMSB(request.getTenantId().getId().getMostSignificantBits()) @@ -399,6 +434,39 @@ public class ProtoUtils { return builder.build(); } + public static ApiUsageRecordKeyProto toProto(ApiUsageRecordKey apiUsageRecordKey) { + return switch (apiUsageRecordKey) { + case TRANSPORT_MSG_COUNT -> ApiUsageRecordKeyProto.TRANSPORT_MSG_COUNT; + case TRANSPORT_DP_COUNT -> ApiUsageRecordKeyProto.TRANSPORT_DP_COUNT; + case STORAGE_DP_COUNT -> ApiUsageRecordKeyProto.STORAGE_DP_COUNT; + case RE_EXEC_COUNT -> ApiUsageRecordKeyProto.RE_EXEC_COUNT; + case JS_EXEC_COUNT -> ApiUsageRecordKeyProto.JS_EXEC_COUNT; + case TBEL_EXEC_COUNT -> ApiUsageRecordKeyProto.TBEL_EXEC_COUNT; + case EMAIL_EXEC_COUNT -> ApiUsageRecordKeyProto.EMAIL_EXEC_COUNT; + case SMS_EXEC_COUNT -> ApiUsageRecordKeyProto.SMS_EXEC_COUNT; + case CREATED_ALARMS_COUNT -> ApiUsageRecordKeyProto.CREATED_ALARMS_COUNT; + case ACTIVE_DEVICES -> ApiUsageRecordKeyProto.ACTIVE_DEVICES; + case INACTIVE_DEVICES -> ApiUsageRecordKeyProto.INACTIVE_DEVICES; + }; + } + + public static ApiUsageRecordKey fromProto(ApiUsageRecordKeyProto proto) { + return switch (proto) { + case UNRECOGNIZED -> null; + case TRANSPORT_MSG_COUNT -> ApiUsageRecordKey.TRANSPORT_MSG_COUNT; + case TRANSPORT_DP_COUNT -> ApiUsageRecordKey.TRANSPORT_DP_COUNT; + case STORAGE_DP_COUNT -> ApiUsageRecordKey.STORAGE_DP_COUNT; + case RE_EXEC_COUNT -> ApiUsageRecordKey.RE_EXEC_COUNT; + case JS_EXEC_COUNT -> ApiUsageRecordKey.JS_EXEC_COUNT; + case TBEL_EXEC_COUNT -> ApiUsageRecordKey.TBEL_EXEC_COUNT; + case EMAIL_EXEC_COUNT -> ApiUsageRecordKey.EMAIL_EXEC_COUNT; + case SMS_EXEC_COUNT -> ApiUsageRecordKey.SMS_EXEC_COUNT; + case CREATED_ALARMS_COUNT -> ApiUsageRecordKey.CREATED_ALARMS_COUNT; + case ACTIVE_DEVICES -> ApiUsageRecordKey.ACTIVE_DEVICES; + case INACTIVE_DEVICES -> ApiUsageRecordKey.INACTIVE_DEVICES; + }; + } + private static ToDeviceActorNotificationMsg fromProto(TransportProtos.DeviceAttributesEventMsgProto proto) { return new DeviceAttributesEventNotificationMsg( TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), @@ -627,6 +695,96 @@ public class ProtoUtils { return new BaseAttributeKvEntry(entry, proto.getLastUpdateTs(), proto.hasVersion() ? proto.getVersion() : null); } + public static BasicKvEntry basicKvEntryFromProto(TransportProtos.AttributeValueProto proto) { + boolean hasValue = proto.getHasV(); + String key = proto.getKey(); + return switch (proto.getType()) { + case BOOLEAN_V -> new BooleanDataEntry(key, hasValue ? proto.getBoolV() : null); + case LONG_V -> new LongDataEntry(key, hasValue ? proto.getLongV() : null); + case DOUBLE_V -> new DoubleDataEntry(key, hasValue ? proto.getDoubleV() : null); + case STRING_V -> new StringDataEntry(key, hasValue ? proto.getStringV() : null); + case JSON_V -> new JsonDataEntry(key, hasValue ? proto.getJsonV() : null); + default -> null; + }; + } + + public static BasicKvEntry fromProto(KeyValueProto proto) { + String key = proto.getKey(); + return switch (proto.getType()) { + case BOOLEAN_V -> new BooleanDataEntry(key, proto.getBoolV()); + case LONG_V -> new LongDataEntry(key, proto.getLongV()); + case DOUBLE_V -> new DoubleDataEntry(key, proto.getDoubleV()); + case STRING_V -> new StringDataEntry(key, proto.getStringV()); + case JSON_V -> new JsonDataEntry(key, proto.getJsonV()); + default -> null; + }; + } + + public static BasicKvEntry basicKvEntryFromKvEntry(KvEntry kvEntry) { + String key = kvEntry.getKey(); + return switch (kvEntry.getDataType()) { + case BOOLEAN -> new BooleanDataEntry(key, kvEntry.getBooleanValue().orElse(null)); + case LONG -> new LongDataEntry(key, kvEntry.getLongValue().orElse(null)); + case DOUBLE -> new DoubleDataEntry(key, kvEntry.getDoubleValue().orElse(null)); + case STRING -> new StringDataEntry(key, kvEntry.getStrValue().orElse(null)); + case JSON -> new JsonDataEntry(key, kvEntry.getJsonValue().orElse(null)); + }; + } + + public static TsKvEntry fromProto(TransportProtos.TsKvProto proto) { + TransportProtos.KeyValueProto kvProto = proto.getKv(); + String key = kvProto.getKey(); + KvEntry entry = switch (kvProto.getType()) { + case BOOLEAN_V -> new BooleanDataEntry(key, kvProto.getBoolV()); + case LONG_V -> new LongDataEntry(key, kvProto.getLongV()); + case DOUBLE_V -> new DoubleDataEntry(key, kvProto.getDoubleV()); + case STRING_V -> new StringDataEntry(key, kvProto.getStringV()); + case JSON_V -> new JsonDataEntry(key, kvProto.getJsonV()); + default -> null; + }; + return new BasicTsKvEntry(proto.getTs(), entry, proto.hasVersion() ? proto.getVersion() : null); + } + + public static TransportProtos.TsKvProto toTsKvProto(TsKvEntry tsKvEntry) { + var builder = TransportProtos.TsKvProto.newBuilder() + .setTs(tsKvEntry.getTs()) + .setKv(toKeyValueProto(tsKvEntry)); + if (tsKvEntry.getVersion() != null) { + builder.setVersion(tsKvEntry.getVersion()); + } + return builder.build(); + } + + public static TransportProtos.KeyValueProto toKeyValueProto(KvEntry kvEntry) { + TransportProtos.KeyValueProto.Builder builder = TransportProtos.KeyValueProto.newBuilder(); + builder.setKey(kvEntry.getKey()); + switch (kvEntry.getDataType()) { + case BOOLEAN: + builder.setType(TransportProtos.KeyValueType.BOOLEAN_V) + .setBoolV(kvEntry.getBooleanValue().orElse(false)); + break; + case LONG: + builder.setType(TransportProtos.KeyValueType.LONG_V) + .setLongV(kvEntry.getLongValue().orElse(0L)); + break; + case DOUBLE: + builder.setType(TransportProtos.KeyValueType.DOUBLE_V) + .setDoubleV(kvEntry.getDoubleValue().orElse(0.0)); + break; + case STRING: + builder.setType(TransportProtos.KeyValueType.STRING_V) + .setStringV(kvEntry.getStrValue().orElse("")); + break; + case JSON: + builder.setType(TransportProtos.KeyValueType.JSON_V) + .setJsonV(kvEntry.getJsonValue().orElse("{}")); + break; + default: + throw new IllegalArgumentException("Unsupported KvEntry data type: " + kvEntry.getDataType()); + } + return builder.build(); + } + public static TransportProtos.DeviceProto toProto(Device device) { var builder = TransportProtos.DeviceProto.newBuilder() .setTenantIdMSB(device.getTenantId().getId().getMostSignificantBits()) diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 1ecd4e6a3a..938a1692ae 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -59,6 +59,22 @@ enum EntityTypeProto { DOMAIN = 36; MOBILE_APP = 37; MOBILE_APP_BUNDLE = 38; + CALCULATED_FIELD = 39; + CALCULATED_FIELD_LINK = 40; +} + +enum ApiUsageRecordKeyProto { + TRANSPORT_MSG_COUNT = 0; + TRANSPORT_DP_COUNT = 1; + STORAGE_DP_COUNT = 2; + RE_EXEC_COUNT = 3; + JS_EXEC_COUNT = 4; + TBEL_EXEC_COUNT = 5; + EMAIL_EXEC_COUNT = 6; + SMS_EXEC_COUNT = 7; + CREATED_ALARMS_COUNT = 8; + ACTIVE_DEVICES = 9; + INACTIVE_DEVICES = 10; } /** @@ -70,6 +86,7 @@ message ServiceInfo { repeated string transports = 6; SystemInfoProto systemInfo = 10; repeated string assignedTenantProfiles = 11; + string label = 12; } message SystemInfoProto { @@ -170,17 +187,47 @@ message AttributeValueProto { optional int64 version = 10; } +message AttributeKvProto { + int64 entityIdMSB = 1; + int64 entityIdLSB = 2; + EntityTypeProto entityType = 3; + AttributeScopeProto scope = 4; + string key = 5; + int64 version = 6; + DataPointProto dataPoint = 7; +} + message TsKvProto { int64 ts = 1; KeyValueProto kv = 2; optional int64 version = 3; } +message LatestTsKvProto { + int64 entityIdMSB = 1; + int64 entityIdLSB = 2; + EntityTypeProto entityType = 3; + string key = 4; + int64 version = 5; + DataPointProto dataPoint = 6; +} + message TsKvListProto { int64 ts = 1; repeated KeyValueProto kv = 2; } +message DataPointProto { + int64 ts = 1; + optional bool boolV = 2; + optional int64 longV = 3; + optional double doubleV = 4; + optional string stringV = 5; + optional bytes compressedStringV = 6; + optional string jsonV = 7; + optional bytes compressedJsonV = 8; +} + message DeviceInfoProto { int64 tenantIdMSB = 1; int64 tenantIdLSB = 2; @@ -483,6 +530,10 @@ message ImageCacheKeyProto { optional string publicResourceKey = 2; } +message ToEdqsCoreServiceMsg { + bytes value = 1; +} + message LwM2MRegistrationRequestMsg { string tenantId = 1; string endpoint = 2; @@ -772,6 +823,76 @@ message DeviceInactivityProto { int64 lastInactivityTime = 5; } +message DeviceInactivityTimeoutUpdateProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 deviceIdMSB = 3; + int64 deviceIdLSB = 4; + int64 inactivityTimeout = 5; +} + +message CalculatedFieldTelemetryMsgProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + string entityType = 3; + int64 entityIdMSB = 4; + int64 entityIdLSB = 5; + repeated CalculatedFieldIdProto previousCalculatedFields = 7; + repeated TsKvProto tsData = 9; + AttributeScopeProto scope = 10; + repeated AttributeValueProto attrData = 11; + repeated string removedTsKeys = 12; + repeated string removedAttrKeys = 13; + int64 tbMsgIdMSB = 14; + int64 tbMsgIdLSB = 15; + string tbMsgType = 16; +} + +message CalculatedFieldLinkedTelemetryMsgProto { + CalculatedFieldTelemetryMsgProto msg = 1; + repeated CalculatedFieldEntityCtxIdProto links = 2; +} + +message CalculatedFieldEntityCtxIdProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 calculatedFieldIdMSB = 3; + int64 calculatedFieldIdLSB = 4; + string entityType = 5; + int64 entityIdMSB = 6; + int64 entityIdLSB = 7; +} + +message CalculatedFieldIdProto { + int64 calculatedFieldIdMSB = 1; + int64 calculatedFieldIdLSB = 2; +} + +message SingleValueArgumentProto { + string argName = 1; + TsValueProto value = 2; + int64 version = 3; +} + +message TsDoubleValProto { + int64 ts = 1; + double value = 2; +} + +message TsRollingArgumentProto { + string key = 1; + int32 limit = 2; + int64 timeWindow = 3; + repeated TsDoubleValProto tsValue = 4; +} + +message CalculatedFieldStateProto { + CalculatedFieldEntityCtxIdProto id = 1; + string type = 2; + repeated SingleValueArgumentProto singleValueArguments = 3; + repeated TsRollingArgumentProto rollingValueArguments = 4; +} + //Used to report session state to tb-Service and persist this state in the cache on the tb-Service level. message SubscriptionInfoProto { int64 lastActivityTime = 1; @@ -1015,7 +1136,11 @@ message TbAttributeDeleteProto { int64 tenantIdLSB = 5; string scope = 6; repeated string keys = 7; - bool notifyDevice = 8; + // DEPRECATED. FOR REMOVAL + // Since 4.0, this field is no longer used. + // Device notifications are now handled directly by DefaultTelemetrySubscriptionService, + // eliminating the need to pass this parameter through the queue and proto to DefaultSubscriptionManagerService. + optional bool notifyDevice = 8 [deprecated = true]; } message TbTimeSeriesDeleteProto { @@ -1127,6 +1252,13 @@ message ComponentLifecycleMsgProto { int64 entityIdMSB = 4; int64 entityIdLSB = 5; ComponentLifecycleEvent event = 6; + //Since 4.0. TODO: replace the DeviceNameOrTypeUpdateMsgProto + string oldName = 7; + string name = 8; + int64 oldProfileIdMSB = 9; + int64 oldProfileIdLSB = 10; + int64 profileIdMSB = 11; + int64 profileIdLSB = 12; } message EdgeEventMsgProto { @@ -1515,6 +1647,7 @@ message ToCoreMsg { DeviceConnectProto deviceConnectMsg = 50; DeviceDisconnectProto deviceDisconnectMsg = 51; DeviceInactivityProto deviceInactivityMsg = 52; + DeviceInactivityTimeoutUpdateProto deviceInactivityTimeoutUpdateMsg = 53; } /* High priority messages with low latency are handled by ThingsBoard Core Service separately */ @@ -1533,6 +1666,7 @@ message ToCoreNotificationMsg { ToEdgeSyncRequestMsgProto toEdgeSyncRequest = 11 [deprecated = true]; FromEdgeSyncResponseMsgProto fromEdgeSyncResponse = 12 [deprecated = true]; ResourceCacheInvalidateMsg resourceCacheInvalidateMsg = 13; + ToEdqsCoreServiceMsg toEdqsCoreServiceMsg = 17; RestApiCallResponseMsgProto restApiCallResponseMsg = 50; } @@ -1553,6 +1687,15 @@ message ToEdgeEventNotificationMsg { EdgeEventMsgProto edgeEventMsg = 1; } +message ToCalculatedFieldMsg { + CalculatedFieldTelemetryMsgProto telemetryMsg = 1; + CalculatedFieldLinkedTelemetryMsgProto linkedTelemetryMsg = 2; +} + +message ToCalculatedFieldNotificationMsg { + CalculatedFieldLinkedTelemetryMsgProto linkedTelemetryMsg = 1; +} + /* Messages that are handled by ThingsBoard RuleEngine Service */ message ToRuleEngineMsg { int64 tenantIdMSB = 1; @@ -1589,12 +1732,25 @@ message ToTransportMsg { repeated QueueDeleteMsg queueDeleteMsgs = 16; } -message UsageStatsKVProto{ - string key = 1; +message UsageStatsKVProto { + string key = 1 [deprecated=true]; int64 value = 2; + ApiUsageRecordKeyProto recordKey = 3; } message ToUsageStatsServiceMsg { + int64 tenantIdMSB = 1 [deprecated=true]; + int64 tenantIdLSB = 2 [deprecated=true]; + int64 entityIdMSB = 3 [deprecated=true]; + int64 entityIdLSB = 4 [deprecated=true]; + repeated UsageStatsKVProto values = 5 [deprecated=true]; + int64 customerIdMSB = 6 [deprecated=true]; + int64 customerIdLSB = 7 [deprecated=true]; + string serviceId = 8; + repeated UsageStatsServiceMsg msgs = 9; +} + +message UsageStatsServiceMsg { int64 tenantIdMSB = 1; int64 tenantIdLSB = 2; int64 entityIdMSB = 3; @@ -1602,7 +1758,6 @@ message ToUsageStatsServiceMsg { repeated UsageStatsKVProto values = 5; int64 customerIdMSB = 6; int64 customerIdLSB = 7; - string serviceId = 8; } message ToOtaPackageStateServiceMsg { @@ -1661,3 +1816,33 @@ message HousekeeperTaskProto { int32 attempt = 50; repeated string errors = 51; } + +message ToEdqsMsg { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 customerIdMSB = 3; + int64 customerIdLSB = 4; + int64 ts = 5; + EdqsEventMsg eventMsg = 6; + EdqsRequestMsg requestMsg = 7; +} + +message FromEdqsMsg { + EdqsResponseMsg responseMsg = 1; +} + +message EdqsEventMsg { + string key = 1; + string objectType = 2; + bytes data = 3; + string eventType = 4; + optional int64 version = 5; +} + +message EdqsRequestMsg { + string value = 1; +} + +message EdqsResponseMsg { + string value = 1; +} diff --git a/common/queue/pom.xml b/common/queue/pom.xml index c0e51318d8..e28ebfbcf0 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -116,6 +116,10 @@ org.apache.curator curator-recipes + + org.xerial.snappy + snappy-java + org.springframework.boot spring-boot-starter-test diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java index 7a99be1997..7e7de64a5c 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java @@ -31,7 +31,6 @@ import java.util.Set; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; -import java.util.stream.Collectors; import static java.util.Collections.emptyList; @@ -95,9 +94,8 @@ public abstract class AbstractTbQueueConsumerTemplate i partitions = subscribeQueue.poll(); } if (!subscribed) { - List topicNames = getFullTopicNames(); - log.info("Subscribing to topics {}", topicNames); - doSubscribe(topicNames); + log.info("Subscribing to {}", partitions); + doSubscribe(partitions); subscribed = true; } records = partitions.isEmpty() ? emptyList() : doPoll(durationInMillis); @@ -120,9 +118,9 @@ public abstract class AbstractTbQueueConsumerTemplate i if (record != null) { result.add(decode(record)); } - } catch (IOException e) { - log.error("Failed decode record: [{}]", record); - throw new RuntimeException("Failed to decode record: ", e); + } catch (Exception e) { + log.error("Failed to decode record {}", record, e); + throw new RuntimeException("Failed to decode record " + record, e); } }); return result; @@ -149,6 +147,9 @@ public abstract class AbstractTbQueueConsumerTemplate i @Override public void commit() { if (consumerLock.isLocked()) { + if (stopped) { + return; + } log.error("commit. consumerLock is locked. will wait with no timeout. it looks like a race conditions or deadlock topic " + topic, new RuntimeException("stacktrace")); } consumerLock.lock(); @@ -166,7 +167,7 @@ public abstract class AbstractTbQueueConsumerTemplate i @Override public void unsubscribe() { - log.info("Unsubscribing and stopping consumer for topics {}", getFullTopicNames()); + log.info("Unsubscribing and stopping consumer for {}", partitions); stopped = true; consumerLock.lock(); try { @@ -187,7 +188,7 @@ public abstract class AbstractTbQueueConsumerTemplate i abstract protected T decode(R record) throws IOException; - abstract protected void doSubscribe(List topicNames); + abstract protected void doSubscribe(Set partitions); abstract protected void doCommit(); @@ -198,7 +199,9 @@ public abstract class AbstractTbQueueConsumerTemplate i if (partitions == null) { return Collections.emptyList(); } - return partitions.stream().map(TopicPartitionInfo::getFullTopicName).collect(Collectors.toList()); + return partitions.stream() + .map(TopicPartitionInfo::getFullTopicName) + .toList(); } protected boolean isLongPollingSupported() { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueTemplate.java index 71cf4bc1a5..55e07e5856 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueTemplate.java @@ -24,35 +24,36 @@ public class AbstractTbQueueTemplate { protected static final String RESPONSE_TOPIC_HEADER = "responseTopic"; protected static final String EXPIRE_TS_HEADER = "expireTs"; - protected byte[] uuidToBytes(UUID uuid) { + public static byte[] uuidToBytes(UUID uuid) { ByteBuffer buf = ByteBuffer.allocate(16); buf.putLong(uuid.getMostSignificantBits()); buf.putLong(uuid.getLeastSignificantBits()); return buf.array(); } - protected static UUID bytesToUuid(byte[] bytes) { + public static UUID bytesToUuid(byte[] bytes) { ByteBuffer bb = ByteBuffer.wrap(bytes); long firstLong = bb.getLong(); long secondLong = bb.getLong(); return new UUID(firstLong, secondLong); } - protected byte[] stringToBytes(String string) { + public static byte[] stringToBytes(String string) { return string.getBytes(StandardCharsets.UTF_8); } - protected String bytesToString(byte[] data) { + public static String bytesToString(byte[] data) { return new String(data, StandardCharsets.UTF_8); } - protected static byte[] longToBytes(long x) { + public static byte[] longToBytes(long x) { ByteBuffer longBuffer = ByteBuffer.allocate(Long.BYTES); longBuffer.putLong(0, x); return longBuffer.array(); } - protected static long bytesToLong(byte[] bytes) { + public static long bytesToLong(byte[] bytes) { return ByteBuffer.wrap(bytes).getLong(); } + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/DefaultTbQueueRequestTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/DefaultTbQueueRequestTemplate.java index 6a73155b39..4efb297491 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/DefaultTbQueueRequestTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/DefaultTbQueueRequestTemplate.java @@ -211,6 +211,15 @@ public class DefaultTbQueueRequestTemplate send(Request request, long requestTimeoutNs) { + return send(request, requestTimeoutNs, null); + } + + @Override + public ListenableFuture send(Request request, Integer partition) { + return send(request, this.maxRequestTimeoutNs, partition); + } + + private ListenableFuture send(Request request, long requestTimeoutNs, Integer partition) { if (pendingRequests.mappingCount() >= maxPendingRequests) { log.warn("Pending request map is full [{}]! Consider to increase maxPendingRequests or increase processing performance. Request is {}", maxPendingRequests, request); return Futures.immediateFailedFuture(new RuntimeException("Pending request map is full!")); @@ -227,7 +236,7 @@ public class DefaultTbQueueRequestTemplate future, ResponseMetaData responseMetaData) { + void sendToRequestTemplate(Request request, UUID requestId, Integer partition, SettableFuture future, ResponseMetaData responseMetaData) { log.trace("[{}] Sending request, key [{}], expTime [{}], request {}", requestId, request.getKey(), responseMetaData.expTime, request); if (messagesStats != null) { messagesStats.incrementTotal(); } - requestTemplate.send(TopicPartitionInfo.builder().topic(requestTemplate.getDefaultTopic()).build(), request, new TbQueueCallback() { + TopicPartitionInfo tpi = TopicPartitionInfo.builder() + .topic(requestTemplate.getDefaultTopic()) + .partition(partition) + .useInternalPartition(partition != null) + .build(); + requestTemplate.send(tpi, request, new TbQueueCallback() { @Override public void onSuccess(TbQueueMsgMetadata metadata) { if (messagesStats != null) { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/DefaultTbQueueResponseTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/DefaultTbQueueResponseTemplate.java index 0c925e9334..abd254b269 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/DefaultTbQueueResponseTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/DefaultTbQueueResponseTemplate.java @@ -28,6 +28,7 @@ import org.thingsboard.server.queue.TbQueueProducer; import org.thingsboard.server.queue.TbQueueResponseTemplate; import java.util.List; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -77,9 +78,18 @@ public class DefaultTbQueueResponseTemplate handler) { - this.responseTemplate.init(); + public void subscribe() { requestTemplate.subscribe(); + } + + @Override + public void subscribe(Set partitions) { + requestTemplate.subscribe(partitions); + } + + @Override + public void launch(TbQueueHandler handler) { + this.responseTemplate.init(); loopExecutor.submit(() -> { while (!stopped) { try { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/TbProtoQueueMsg.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/TbProtoQueueMsg.java index 4a5caa35b4..a5bf6c8861 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/TbProtoQueueMsg.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/TbProtoQueueMsg.java @@ -50,6 +50,7 @@ public class TbProtoQueueMsg i @Override public byte[] getData() { - return value.toByteArray(); + return value != null ? value.toByteArray() : null; } + } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/consumer/MainQueueConsumerManager.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/MainQueueConsumerManager.java similarity index 73% rename from application/src/main/java/org/thingsboard/server/service/queue/consumer/MainQueueConsumerManager.java rename to common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/MainQueueConsumerManager.java index ef1831c479..14394bbbe9 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/consumer/MainQueueConsumerManager.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/MainQueueConsumerManager.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.queue.consumer; +package org.thingsboard.server.queue.common.consumer; import lombok.Builder; import lombok.Getter; @@ -23,10 +23,9 @@ import org.thingsboard.server.common.data.queue.QueueConfig; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.queue.TbQueueConsumer; import org.thingsboard.server.queue.TbQueueMsg; +import org.thingsboard.server.queue.common.consumer.TbQueueConsumerManagerTask.UpdateConfigTask; +import org.thingsboard.server.queue.common.consumer.TbQueueConsumerManagerTask.UpdatePartitionsTask; import org.thingsboard.server.queue.discovery.QueueKey; -import org.thingsboard.server.service.queue.ruleengine.QueueEvent; -import org.thingsboard.server.service.queue.ruleengine.TbQueueConsumerManagerTask; -import org.thingsboard.server.service.queue.ruleengine.TbQueueConsumerTask; import java.util.Collection; import java.util.Collections; @@ -34,6 +33,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ExecutorService; @@ -42,19 +42,24 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiFunction; -import java.util.stream.Collectors; +import java.util.function.Consumer; @Slf4j public class MainQueueConsumerManager { + @Getter protected final QueueKey queueKey; @Getter protected C config; protected final MsgPackProcessor msgPackProcessor; protected final BiFunction> consumerCreator; + @Getter protected final ExecutorService consumerExecutor; + @Getter protected final ScheduledExecutorService scheduler; + @Getter protected final ExecutorService taskExecutor; + protected final Consumer uncaughtErrorHandler; private final java.util.Queue tasks = new ConcurrentLinkedQueue<>(); private final ReentrantLock lock = new ReentrantLock(); @@ -70,7 +75,8 @@ public class MainQueueConsumerManager> consumerCreator, ExecutorService consumerExecutor, ScheduledExecutorService scheduler, - ExecutorService taskExecutor) { + ExecutorService taskExecutor, + Consumer uncaughtErrorHandler) { this.queueKey = queueKey; this.config = config; this.msgPackProcessor = msgPackProcessor; @@ -78,6 +84,7 @@ public class MainQueueConsumerManager createConsumerWrapper(C config) { if (config.isConsumerPerPartition()) { - this.consumerWrapper = new ConsumerPerPartitionWrapper(); + return new ConsumerPerPartitionWrapper(); } else { - this.consumerWrapper = new SingleConsumerWrapper(); + return new SingleConsumerWrapper(); } - log.debug("[{}] Initialized consumer for queue: {}", queueKey, config); } public void update(C config) { - addTask(TbQueueConsumerManagerTask.configUpdate(config)); + addTask(new UpdateConfigTask(config)); } public void update(Set partitions) { - addTask(TbQueueConsumerManagerTask.partitionChange(partitions)); + addTask(new UpdatePartitionsTask(partitions)); } protected void addTask(TbQueueConsumerManagerTask todo) { @@ -123,10 +134,10 @@ public class MainQueueConsumerManager consumerLoop = consumerExecutor.submit(() -> { ThingsBoardThreadFactory.updateCurrentThreadName(consumerTask.getKey().toString()); + consumerLoop(consumerTask.getConsumer()); + log.info("[{}] Consumer stopped", consumerTask.getKey()); + try { - consumerLoop(consumerTask.getConsumer()); - } catch (Throwable e) { - log.error("Failure in consumer loop", e); + Runnable callback = consumerTask.getCallback(); + if (callback != null) { + callback.run(); + } + } catch (Throwable t) { + log.error("Failed to execute finish callback", t); } - log.info("[{}] Consumer stopped", consumerTask.getKey()); }); consumerTask.setTask(consumerLoop); } private void consumerLoop(TbQueueConsumer consumer) { - while (!stopped && !consumer.isStopped()) { - try { - List msgs = consumer.poll(config.getPollInterval()); - if (msgs.isEmpty()) { - continue; - } - processMsgs(msgs, consumer, config); - } catch (Exception e) { - if (!consumer.isStopped()) { - log.warn("Failed to process messages from queue", e); - try { - Thread.sleep(config.getPollInterval()); - } catch (InterruptedException e2) { - log.trace("Failed to wait until the server has capacity to handle new requests", e2); + try { + while (!stopped && !consumer.isStopped()) { + try { + List msgs = consumer.poll(config.getPollInterval()); + if (msgs.isEmpty()) { + continue; + } + processMsgs(msgs, consumer, config); + } catch (Exception e) { + if (!consumer.isStopped()) { + log.warn("Failed to process messages from queue", e); + try { + Thread.sleep(config.getPollInterval()); + } catch (InterruptedException e2) { + log.trace("Failed to wait until the server has capacity to handle new requests", e2); + } } } } - } - if (consumer.isStopped()) { + if (consumer.isStopped()) { + consumer.unsubscribe(); + } + } catch (Throwable t) { + log.error("Failure in consumer loop", t); + if (uncaughtErrorHandler != null) { + uncaughtErrorHandler.accept(t); + } consumer.unsubscribe(); } } protected void processMsgs(List msgs, TbQueueConsumer consumer, C config) throws Exception { + log.trace("Processing {} messages", msgs.size()); msgPackProcessor.process(msgs, consumer, config); + log.trace("Processed {} messages", msgs.size()); } public void stop() { @@ -236,13 +262,13 @@ public class MainQueueConsumerManager partitions) { - return partitions.stream().map(TopicPartitionInfo::getFullTopicName).collect(Collectors.joining(", ", "[", "]")); + private void awaitStop(int timeoutSec) { + log.debug("[{}] Waiting for consumers to stop", queueKey); + consumerWrapper.getConsumers().forEach(consumerTask -> consumerTask.awaitCompletion(timeoutSec)); + log.debug("[{}] Unsubscribed and stopped consumers", queueKey); } public interface MsgPackProcessor { @@ -267,15 +293,24 @@ public class MainQueueConsumerManager removedPartitions = new HashSet<>(consumers.keySet()); removedPartitions.removeAll(partitions); - log.info("[{}] Added partitions: {}, removed partitions: {}", queueKey, partitionsToString(addedPartitions), partitionsToString(removedPartitions)); - removedPartitions.forEach((tpi) -> consumers.get(tpi).initiateStop()); - removedPartitions.forEach((tpi) -> consumers.remove(tpi).awaitCompletion()); + log.info("[{}] Added partitions: {}, removed partitions: {}", queueKey, addedPartitions, removedPartitions); + removePartitions(removedPartitions); + addPartitions(addedPartitions, null); + } - addedPartitions.forEach((tpi) -> { + protected void removePartitions(Set removedPartitions) { + removedPartitions.forEach((tpi) -> Optional.ofNullable(consumers.get(tpi)).ifPresent(TbQueueConsumerTask::initiateStop)); + removedPartitions.forEach((tpi) -> Optional.ofNullable(consumers.remove(tpi)).ifPresent(TbQueueConsumerTask::awaitCompletion)); + } + + protected void addPartitions(Set partitions, Consumer onStop) { + partitions.forEach(tpi -> { Integer partitionId = tpi.getPartition().orElse(-1); String key = queueKey + "-" + partitionId; - TbQueueConsumerTask consumer = new TbQueueConsumerTask<>(key, () -> consumerCreator.apply(config, partitionId)); + Runnable callback = onStop != null ? () -> onStop.accept(tpi) : null; + + TbQueueConsumerTask consumer = new TbQueueConsumerTask<>(key, () -> consumerCreator.apply(config, partitionId), callback); consumers.put(tpi, consumer); consumer.subscribe(Set.of(tpi)); launchConsumer(consumer); @@ -293,7 +328,7 @@ public class MainQueueConsumerManager partitions) { - log.info("[{}] New partitions: {}", queueKey, partitionsToString(partitions)); + log.info("[{}] New partitions: {}", queueKey, partitions); if (partitions.isEmpty()) { if (consumer != null && consumer.isRunning()) { consumer.initiateStop(); @@ -304,7 +339,7 @@ public class MainQueueConsumerManager(queueKey, () -> consumerCreator.apply(config, null)); // no partitionId passed + consumer = new TbQueueConsumerTask<>(queueKey, () -> consumerCreator.apply(config, null), null); // no partitionId passed } consumer.subscribe(partitions); if (!consumer.isRunning()) { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/PartitionedQueueConsumerManager.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/PartitionedQueueConsumerManager.java new file mode 100644 index 0000000000..f25a98adf4 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/PartitionedQueueConsumerManager.java @@ -0,0 +1,94 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.common.consumer; + +import lombok.Builder; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.queue.QueueConfig; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.TbQueueMsg; +import org.thingsboard.server.queue.common.consumer.TbQueueConsumerManagerTask.AddPartitionsTask; +import org.thingsboard.server.queue.common.consumer.TbQueueConsumerManagerTask.DeletePartitionsTask; +import org.thingsboard.server.queue.common.consumer.TbQueueConsumerManagerTask.RemovePartitionsTask; +import org.thingsboard.server.queue.discovery.QueueKey; + +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.BiFunction; +import java.util.function.Consumer; + +@Slf4j +public class PartitionedQueueConsumerManager extends MainQueueConsumerManager { + + private final ConsumerPerPartitionWrapper consumerWrapper; + private final TbQueueAdmin queueAdmin; + @Getter + private final String topic; + + @Builder(builderMethodName = "create") // not to conflict with super.builder() + public PartitionedQueueConsumerManager(QueueKey queueKey, String topic, long pollInterval, MsgPackProcessor msgPackProcessor, + BiFunction> consumerCreator, TbQueueAdmin queueAdmin, + ExecutorService consumerExecutor, ScheduledExecutorService scheduler, + ExecutorService taskExecutor, Consumer uncaughtErrorHandler) { + super(queueKey, QueueConfig.of(true, pollInterval), msgPackProcessor, consumerCreator, consumerExecutor, scheduler, taskExecutor, uncaughtErrorHandler); + this.topic = topic; + this.consumerWrapper = (ConsumerPerPartitionWrapper) super.consumerWrapper; + this.queueAdmin = queueAdmin; + } + + @Override + protected void processTask(TbQueueConsumerManagerTask task) { + if (task instanceof AddPartitionsTask addPartitionsTask) { + log.info("[{}] Added partitions: {}", queueKey, addPartitionsTask.partitions()); + consumerWrapper.addPartitions(addPartitionsTask.partitions(), addPartitionsTask.onStop()); + } else if (task instanceof RemovePartitionsTask removePartitionsTask) { + log.info("[{}] Removed partitions: {}", queueKey, removePartitionsTask.partitions()); + consumerWrapper.removePartitions(removePartitionsTask.partitions()); + } else if (task instanceof DeletePartitionsTask deletePartitionsTask) { + log.info("[{}] Removing partitions and deleting topics: {}", queueKey, deletePartitionsTask.partitions()); + consumerWrapper.removePartitions(deletePartitionsTask.partitions()); + deletePartitionsTask.partitions().forEach(tpi -> { + String topic = tpi.getFullTopicName(); + try { + queueAdmin.deleteTopic(topic); + } catch (Throwable t) { + log.error("Failed to delete topic {}", topic, t); + } + }); + } + } + + public void addPartitions(Set partitions) { + addPartitions(partitions, null); + } + + public void addPartitions(Set partitions, Consumer onStop) { + addTask(new AddPartitionsTask(partitions, onStop)); + } + + public void removePartitions(Set partitions) { + addTask(new RemovePartitionsTask(partitions)); + } + + public void delete(Set partitions) { + addTask(new DeletePartitionsTask(partitions)); + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueTaskType.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueTaskType.java new file mode 100644 index 0000000000..84cb3c9382 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueTaskType.java @@ -0,0 +1,25 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.common.consumer; + +import java.io.Serializable; + +public enum QueueTaskType implements Serializable { + + UPDATE_PARTITIONS, UPDATE_CONFIG, DELETE, + ADD_PARTITIONS, REMOVE_PARTITIONS, DELETE_PARTITIONS + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/TbQueueConsumerManagerTask.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/TbQueueConsumerManagerTask.java new file mode 100644 index 0000000000..e0dd9b808b --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/TbQueueConsumerManagerTask.java @@ -0,0 +1,70 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.common.consumer; + +import org.thingsboard.server.common.data.queue.QueueConfig; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; + +import java.util.Set; +import java.util.function.Consumer; + +public interface TbQueueConsumerManagerTask { + + QueueTaskType getType(); + + record DeleteQueueTask(boolean drainQueue) implements TbQueueConsumerManagerTask { + @Override + public QueueTaskType getType() { + return QueueTaskType.DELETE; + } + } + + record UpdateConfigTask(QueueConfig config) implements TbQueueConsumerManagerTask { + @Override + public QueueTaskType getType() { + return QueueTaskType.UPDATE_CONFIG; + } + } + + record UpdatePartitionsTask(Set partitions) implements TbQueueConsumerManagerTask { + @Override + public QueueTaskType getType() { + return QueueTaskType.UPDATE_PARTITIONS; + } + } + + record AddPartitionsTask(Set partitions, Consumer onStop) implements TbQueueConsumerManagerTask { + @Override + public QueueTaskType getType() { + return QueueTaskType.ADD_PARTITIONS; + } + } + + record RemovePartitionsTask(Set partitions) implements TbQueueConsumerManagerTask { + @Override + public QueueTaskType getType() { + return QueueTaskType.REMOVE_PARTITIONS; + } + } + + record DeletePartitionsTask(Set partitions) implements TbQueueConsumerManagerTask { + @Override + public QueueTaskType getType() { + return QueueTaskType.REMOVE_PARTITIONS; + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbQueueConsumerTask.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/TbQueueConsumerTask.java similarity index 84% rename from application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbQueueConsumerTask.java rename to common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/TbQueueConsumerTask.java index 16642d5cf4..28066d9a91 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbQueueConsumerTask.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/TbQueueConsumerTask.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.queue.ruleengine; +package org.thingsboard.server.queue.common.consumer; import lombok.Getter; import lombok.Setter; @@ -35,14 +35,17 @@ public class TbQueueConsumerTask { private final Object key; private volatile TbQueueConsumer consumer; private volatile Supplier> consumerSupplier; + @Getter + private final Runnable callback; @Setter private Future task; - public TbQueueConsumerTask(Object key, Supplier> consumerSupplier) { + public TbQueueConsumerTask(Object key, Supplier> consumerSupplier, Runnable callback) { this.key = key; this.consumer = null; this.consumerSupplier = consumerSupplier; + this.callback = callback; } public TbQueueConsumer getConsumer() { @@ -70,13 +73,21 @@ public class TbQueueConsumerTask { } public void awaitCompletion() { + awaitCompletion(30); + } + + public void awaitCompletion(int timeoutSec) { log.trace("[{}] Awaiting finish", key); if (isRunning()) { try { - task.get(30, TimeUnit.SECONDS); + if (timeoutSec > 0) { + task.get(timeoutSec, TimeUnit.SECONDS); + } else { + task.get(); + } log.trace("[{}] Awaited finish", key); } catch (Exception e) { - log.warn("[{}] Failed to await for consumer to stop", key, e); + log.warn("[{}] Failed to await for consumer to stop (timeout {} sec)", key, timeoutSec, e); } task = null; } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/state/DefaultQueueStateService.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/state/DefaultQueueStateService.java new file mode 100644 index 0000000000..be019caaa7 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/state/DefaultQueueStateService.java @@ -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. + */ +package org.thingsboard.server.queue.common.state; + +import org.thingsboard.server.queue.TbQueueMsg; +import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; + +public class DefaultQueueStateService extends QueueStateService { + + public DefaultQueueStateService(PartitionedQueueConsumerManager eventConsumer) { + super(eventConsumer); + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/state/KafkaQueueStateService.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/state/KafkaQueueStateService.java new file mode 100644 index 0000000000..9adc6bb996 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/state/KafkaQueueStateService.java @@ -0,0 +1,81 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.common.state; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.queue.TbQueueMsg; +import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; +import org.thingsboard.server.queue.discovery.QueueKey; + +import java.util.Set; + +import static org.thingsboard.server.common.msg.queue.TopicPartitionInfo.withTopic; + +@Slf4j +public class KafkaQueueStateService extends QueueStateService { + + private final PartitionedQueueConsumerManager stateConsumer; + + public KafkaQueueStateService(PartitionedQueueConsumerManager eventConsumer, PartitionedQueueConsumerManager stateConsumer) { + super(eventConsumer); + this.stateConsumer = stateConsumer; + } + + @Override + protected void addPartitions(QueueKey queueKey, Set partitions) { + Set statePartitions = withTopic(partitions, stateConsumer.getTopic()); + partitionsInProgress.addAll(statePartitions); + stateConsumer.addPartitions(statePartitions, statePartition -> { + var readLock = partitionsLock.readLock(); + readLock.lock(); + try { + partitionsInProgress.remove(statePartition); + log.info("Finished partition {} (still in progress: {})", statePartition, partitionsInProgress); + if (partitionsInProgress.isEmpty()) { + log.info("All partitions processed"); + } + + TopicPartitionInfo eventPartition = statePartition.withTopic(eventConsumer.getTopic()); + if (this.partitions.get(queueKey).contains(eventPartition)) { + eventConsumer.addPartitions(Set.of(eventPartition)); + } + } finally { + readLock.unlock(); + } + }); + } + + @Override + protected void removePartitions(QueueKey queueKey, Set partitions) { + super.removePartitions(queueKey, partitions); + stateConsumer.removePartitions(withTopic(partitions, stateConsumer.getTopic())); + } + + @Override + protected void deletePartitions(Set partitions) { + super.deletePartitions(partitions); + stateConsumer.delete(withTopic(partitions, stateConsumer.getTopic())); + } + + @Override + public void stop() { + super.stop(); + stateConsumer.stop(); + stateConsumer.awaitStop(); + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/state/QueueStateService.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/state/QueueStateService.java new file mode 100644 index 0000000000..29426fab63 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/state/QueueStateService.java @@ -0,0 +1,114 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.common.state; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.queue.TbQueueMsg; +import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; +import org.thingsboard.server.queue.discovery.QueueKey; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import static org.thingsboard.server.common.msg.queue.TopicPartitionInfo.withTopic; + +@Slf4j +public abstract class QueueStateService { + + protected final PartitionedQueueConsumerManager eventConsumer; + + @Getter + protected final Map> partitions = new HashMap<>(); + protected final Set partitionsInProgress = ConcurrentHashMap.newKeySet(); + protected boolean initialized; + + protected final ReadWriteLock partitionsLock = new ReentrantReadWriteLock(); + + protected QueueStateService(PartitionedQueueConsumerManager eventConsumer) { + this.eventConsumer = eventConsumer; + } + + public void update(QueueKey queueKey, Set newPartitions) { + newPartitions = withTopic(newPartitions, eventConsumer.getTopic()); + var writeLock = partitionsLock.writeLock(); + writeLock.lock(); + Set oldPartitions = this.partitions.getOrDefault(queueKey, Collections.emptySet()); + Set addedPartitions; + Set removedPartitions; + try { + addedPartitions = new HashSet<>(newPartitions); + addedPartitions.removeAll(oldPartitions); + removedPartitions = new HashSet<>(oldPartitions); + removedPartitions.removeAll(newPartitions); + this.partitions.put(queueKey, newPartitions); + } finally { + writeLock.unlock(); + } + + if (!removedPartitions.isEmpty()) { + removePartitions(queueKey, removedPartitions); + } + + if (!addedPartitions.isEmpty()) { + addPartitions(queueKey, addedPartitions); + } + initialized = true; + } + + protected void addPartitions(QueueKey queueKey, Set partitions) { + eventConsumer.addPartitions(partitions); + } + + protected void removePartitions(QueueKey queueKey, Set partitions) { + eventConsumer.removePartitions(partitions); + } + + public void delete(Set partitions) { + if (partitions.isEmpty()) { + return; + } + var writeLock = partitionsLock.writeLock(); + writeLock.lock(); + try { + this.partitions.values().forEach(tpis -> tpis.removeAll(partitions)); + } finally { + writeLock.unlock(); + } + deletePartitions(partitions); + } + + protected void deletePartitions(Set partitions) { + eventConsumer.delete(withTopic(partitions, eventConsumer.getTopic())); + } + + public Set getPartitionsInProgress() { + return initialized ? partitionsInProgress : null; + } + + public void stop() { + eventConsumer.stop(); + eventConsumer.awaitStop(); + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/DefaultTbServiceInfoProvider.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/DefaultTbServiceInfoProvider.java index a2753b8832..609d3f8eee 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/DefaultTbServiceInfoProvider.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/DefaultTbServiceInfoProvider.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.util.CollectionsUtil; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ServiceInfo; +import org.thingsboard.server.queue.edqs.EdqsConfig; import org.thingsboard.server.queue.util.AfterContextReady; import java.net.InetAddress; @@ -39,12 +40,7 @@ import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; -import static org.thingsboard.common.util.SystemUtil.getCpuCount; -import static org.thingsboard.common.util.SystemUtil.getCpuUsage; -import static org.thingsboard.common.util.SystemUtil.getDiscSpaceUsage; -import static org.thingsboard.common.util.SystemUtil.getMemoryUsage; -import static org.thingsboard.common.util.SystemUtil.getTotalDiscSpace; -import static org.thingsboard.common.util.SystemUtil.getTotalMemory; +import static org.thingsboard.common.util.SystemUtil.*; @Component @@ -63,6 +59,9 @@ public class DefaultTbServiceInfoProvider implements TbServiceInfoProvider { @Value("${service.rule_engine.assigned_tenant_profiles:}") private Set assignedTenantProfiles; + @Autowired + private EdqsConfig edqsConfig; + @Autowired private ApplicationContext applicationContext; @@ -87,6 +86,11 @@ public class DefaultTbServiceInfoProvider implements TbServiceInfoProvider { if (!serviceTypes.contains(ServiceType.TB_RULE_ENGINE) || assignedTenantProfiles == null) { assignedTenantProfiles = Collections.emptySet(); } + if (serviceTypes.contains(ServiceType.EDQS)) { + if (StringUtils.isBlank(edqsConfig.getLabel())) { + edqsConfig.setLabel(serviceId); + } + } generateNewServiceInfoWithCurrentSystemInfo(); } @@ -123,6 +127,7 @@ public class DefaultTbServiceInfoProvider implements TbServiceInfoProvider { if (CollectionsUtil.isNotEmpty(assignedTenantProfiles)) { builder.addAllAssignedTenantProfiles(assignedTenantProfiles.stream().map(UUID::toString).collect(Collectors.toList())); } + builder.setLabel(edqsConfig.getLabel()); return serviceInfo = builder.build(); } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/HashPartitionService.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/HashPartitionService.java index c86d9d1551..3a76d50825 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/HashPartitionService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/HashPartitionService.java @@ -20,9 +20,11 @@ import com.google.common.hash.Hashing; import jakarta.annotation.PostConstruct; import lombok.Data; import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.exception.TenantNotFoundException; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; @@ -50,7 +52,10 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.stream.Collectors; +import java.util.stream.Stream; +import static org.thingsboard.server.common.data.DataConstants.CF_QUEUE_NAME; +import static org.thingsboard.server.common.data.DataConstants.CF_STATES_QUEUE_NAME; import static org.thingsboard.server.common.data.DataConstants.EDGE_QUEUE_NAME; import static org.thingsboard.server.common.data.DataConstants.MAIN_QUEUE_NAME; @@ -58,10 +63,14 @@ import static org.thingsboard.server.common.data.DataConstants.MAIN_QUEUE_NAME; @Slf4j public class HashPartitionService implements PartitionService { - @Value("${queue.core.topic}") + @Value("${queue.core.topic:tb_core}") private String coreTopic; @Value("${queue.core.partitions:10}") private Integer corePartitions; + @Value("${queue.calculated_fields.event_topic:tb_cf_event}") + private String cfEventTopic; + @Value("${queue.calculated_fields.state_topic:tb_cf_state}") + private String cfStateTopic; @Value("${queue.vc.topic:tb_version_control}") private String vcTopic; @Value("${queue.vc.partitions:10}") @@ -70,6 +79,8 @@ public class HashPartitionService implements PartitionService { private String edgeTopic; @Value("${queue.edge.partitions:10}") private Integer edgePartitions; + @Value("${queue.edqs.partitions:12}") + private Integer edqsPartitions; @Value("${queue.partitions.hash_function_name:murmur3_128}") private String hashFunctionName; @@ -108,6 +119,7 @@ public class HashPartitionService implements PartitionService { @PostConstruct public void init() { this.hashFunction = forName(hashFunctionName); + QueueKey coreKey = new QueueKey(ServiceType.TB_CORE); partitionSizesMap.put(coreKey, corePartitions); partitionTopicsMap.put(coreKey, coreTopic); @@ -123,6 +135,10 @@ public class HashPartitionService implements PartitionService { QueueKey edgeKey = coreKey.withQueueName(EDGE_QUEUE_NAME); partitionSizesMap.put(edgeKey, edgePartitions); partitionTopicsMap.put(edgeKey, edgeTopic); + + QueueKey edqsKey = new QueueKey(ServiceType.EDQS); + partitionSizesMap.put(edqsKey, edqsPartitions); + partitionTopicsMap.put(edqsKey, "edqs"); // placeholder, not used } @AfterStartUp(order = AfterStartUp.QUEUE_INFO_INITIALIZATION) @@ -137,12 +153,16 @@ public class HashPartitionService implements PartitionService { return myPartitions.get(queueKey); } + @Override + public String getTopic(QueueKey queueKey) { + return partitionTopicsMap.get(queueKey); + } + private void doInitRuleEnginePartitions() { List queueRoutingInfoList = getQueueRoutingInfos(); queueRoutingInfoList.forEach(queue -> { QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, queue); - partitionTopicsMap.put(queueKey, queue.getQueueTopic()); - partitionSizesMap.put(queueKey, queue.getPartitions()); + updateQueue(queueKey, queue.getQueueTopic(), queue.getPartitions()); queueConfigs.put(queueKey, new QueueConfig(queue)); }); } @@ -189,8 +209,7 @@ public class HashPartitionService implements PartitionService { QueueRoutingInfo queueRoutingInfo = new QueueRoutingInfo(queueUpdateMsg); TenantId tenantId = queueRoutingInfo.getTenantId(); QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, queueRoutingInfo.getQueueName(), tenantId); - partitionTopicsMap.put(queueKey, queueRoutingInfo.getQueueTopic()); - partitionSizesMap.put(queueKey, queueRoutingInfo.getPartitions()); + updateQueue(queueKey, queueRoutingInfo.getQueueTopic(), queueRoutingInfo.getPartitions()); queueConfigs.put(queueKey, new QueueConfig(queueRoutingInfo)); if (!tenantId.isSysTenantId()) { tenantRoutingInfoMap.remove(tenantId); @@ -201,9 +220,15 @@ public class HashPartitionService implements PartitionService { @Override public void removeQueues(List queueDeleteMsgs) { List queueKeys = queueDeleteMsgs.stream() - .map(queueDeleteMsg -> { + .flatMap(queueDeleteMsg -> { TenantId tenantId = TenantId.fromUUID(new UUID(queueDeleteMsg.getTenantIdMSB(), queueDeleteMsg.getTenantIdLSB())); - return new QueueKey(ServiceType.TB_RULE_ENGINE, queueDeleteMsg.getQueueName(), tenantId); + QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, queueDeleteMsg.getQueueName(), tenantId); + if (queueKey.getQueueName().equals(MAIN_QUEUE_NAME)) { + return Stream.of(queueKey, queueKey.withQueueName(CF_QUEUE_NAME), + queueKey.withQueueName(CF_STATES_QUEUE_NAME)); + } else { + return Stream.of(queueKey); + } }).toList(); queueKeys.forEach(queueKey -> { removeQueue(queueKey); @@ -211,18 +236,40 @@ public class HashPartitionService implements PartitionService { }); if (serviceInfoProvider.isService(ServiceType.TB_RULE_ENGINE)) { publishPartitionChangeEvent(ServiceType.TB_RULE_ENGINE, queueKeys.stream() - .collect(Collectors.toMap(k -> k, k -> Collections.emptySet()))); + .collect(Collectors.toMap(k -> k, k -> Collections.emptySet())), Collections.emptyMap()); } } @Override public void removeTenant(TenantId tenantId) { List queueKeys = partitionSizesMap.keySet().stream() - .filter(queueKey -> tenantId.equals(queueKey.getTenantId())).toList(); + .filter(queueKey -> tenantId.equals(queueKey.getTenantId())) + .flatMap(queueKey -> { + if (queueKey.getQueueName().equals(MAIN_QUEUE_NAME)) { + return Stream.of(queueKey, queueKey.withQueueName(CF_QUEUE_NAME), + queueKey.withQueueName(CF_STATES_QUEUE_NAME)); + } else { + return Stream.of(queueKey); + } + }) + .toList(); queueKeys.forEach(this::removeQueue); evictTenantInfo(tenantId); } + private void updateQueue(QueueKey queueKey, String topic, int partitions) { + partitionTopicsMap.put(queueKey, topic); + partitionSizesMap.put(queueKey, partitions); + if (DataConstants.MAIN_QUEUE_NAME.equals(queueKey.getQueueName())) { + QueueKey cfQueueKey = queueKey.withQueueName(DataConstants.CF_QUEUE_NAME); + partitionTopicsMap.put(cfQueueKey, cfEventTopic); + partitionSizesMap.put(cfQueueKey, partitions); + QueueKey cfStatesQueueKey = queueKey.withQueueName(DataConstants.CF_STATES_QUEUE_NAME); + partitionTopicsMap.put(cfStatesQueueKey, cfStateTopic); + partitionSizesMap.put(cfStatesQueueKey, partitions); + } + } + private void removeQueue(QueueKey queueKey) { myPartitions.remove(queueKey); partitionTopicsMap.remove(queueKey); @@ -354,6 +401,11 @@ public class HashPartitionService implements PartitionService { } } + @Override + public boolean isSystemPartitionMine(ServiceType serviceType) { + return isMyPartition(serviceType, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID); + } + @Override public synchronized void recalculatePartitions(ServiceInfo currentService, List otherServices) { log.info("Recalculating partitions"); @@ -374,9 +426,9 @@ public class HashPartitionService implements PartitionService { partitionSizesMap.forEach((queueKey, size) -> { for (int i = 0; i < size; i++) { try { - ServiceInfo serviceInfo = resolveByPartitionIdx(queueServicesMap.get(queueKey), queueKey, i, responsibleServices); - log.trace("Server responsible for {}[{}] - {}", queueKey, i, serviceInfo != null ? serviceInfo.getServiceId() : "none"); - if (currentService.equals(serviceInfo)) { + List services = resolveByPartitionIdx(queueServicesMap.get(queueKey), queueKey, i, responsibleServices); + log.trace("Server responsible for {}[{}] - {}", queueKey, i, services); + if (services.contains(currentService)) { newPartitions.computeIfAbsent(queueKey, key -> new ArrayList<>()).add(i); } } catch (Exception e) { @@ -390,6 +442,7 @@ public class HashPartitionService implements PartitionService { myPartitions = newPartitions; Map> changedPartitionsMap = new HashMap<>(); + Map> oldPartitionsMap = new HashMap<>(); Set removed = new HashSet<>(); oldPartitions.forEach((queueKey, partitions) -> { @@ -410,19 +463,16 @@ public class HashPartitionService implements PartitionService { myPartitions.forEach((queueKey, partitions) -> { if (!partitions.equals(oldPartitions.get(queueKey))) { - Set tpiList = partitions.stream() - .map(partition -> buildTopicPartitionInfo(queueKey, partition)) - .collect(Collectors.toSet()); - changedPartitionsMap.put(queueKey, tpiList); + changedPartitionsMap.put(queueKey, toTpiList(queueKey, partitions)); + oldPartitionsMap.put(queueKey, toTpiList(queueKey, oldPartitions.get(queueKey))); } }); if (!changedPartitionsMap.isEmpty()) { - Map>> partitionsByServiceType = new HashMap<>(); - changedPartitionsMap.forEach((queueKey, partitions) -> { - partitionsByServiceType.computeIfAbsent(queueKey.getType(), serviceType -> new HashMap<>()) - .put(queueKey, partitions); - }); - partitionsByServiceType.forEach(this::publishPartitionChangeEvent); + changedPartitionsMap.entrySet().stream() + .collect(Collectors.groupingBy(entry -> entry.getKey().getType(), Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))) + .forEach((serviceType, partitionsMap) -> { + publishPartitionChangeEvent(serviceType, partitionsMap, oldPartitionsMap); + }); } if (currentOtherServices == null) { @@ -454,13 +504,15 @@ public class HashPartitionService implements PartitionService { applicationEventPublisher.publishEvent(new ServiceListChangedEvent(otherServices, currentService)); } - private void publishPartitionChangeEvent(ServiceType serviceType, Map> partitionsMap) { - log.info("Partitions changed: {}", System.lineSeparator() + partitionsMap.entrySet().stream() + private void publishPartitionChangeEvent(ServiceType serviceType, + Map> newPartitions, + Map> oldPartitions) { + log.info("Partitions changed: {}", System.lineSeparator() + newPartitions.entrySet().stream() .map(entry -> "[" + entry.getKey() + "] - [" + entry.getValue().stream() .map(tpi -> tpi.getPartition().orElse(-1).toString()).sorted() .collect(Collectors.joining(", ")) + "]") .collect(Collectors.joining(System.lineSeparator()))); - PartitionChangeEvent event = new PartitionChangeEvent(this, serviceType, partitionsMap); + PartitionChangeEvent event = new PartitionChangeEvent(this, serviceType, newPartitions, oldPartitions); try { applicationEventPublisher.publishEvent(event); } catch (Exception e) { @@ -468,6 +520,15 @@ public class HashPartitionService implements PartitionService { } } + private Set toTpiList(QueueKey queueKey, List partitions) { + if (partitions == null) { + return Collections.emptySet(); + } + return partitions.stream() + .map(partition -> buildTopicPartitionInfo(queueKey, partition)) + .collect(Collectors.toSet()); + } + @Override public Set getAllServiceIds(ServiceType serviceType) { return getAllServices(serviceType).stream().map(ServiceInfo::getServiceId).collect(Collectors.toSet()); @@ -496,7 +557,6 @@ public class HashPartitionService implements PartitionService { return result; } - @Override public int resolvePartitionIndex(UUID entityId, int partitions) { int hash = hash(entityId); @@ -598,6 +658,8 @@ public class HashPartitionService implements PartitionService { queueServiceList.computeIfAbsent(new QueueKey(serviceType).withQueueName(EDGE_QUEUE_NAME), key -> new ArrayList<>()).add(instance); } else if (ServiceType.TB_VC_EXECUTOR.equals(serviceType)) { queueServiceList.computeIfAbsent(new QueueKey(serviceType), key -> new ArrayList<>()).add(instance); + } else if (ServiceType.EDQS.equals(serviceType)) { + queueServiceList.computeIfAbsent(new QueueKey(serviceType), key -> new ArrayList<>()).add(instance); } } @@ -606,10 +668,11 @@ public class HashPartitionService implements PartitionService { } } - protected ServiceInfo resolveByPartitionIdx(List servers, QueueKey queueKey, int partition, - Map> responsibleServices) { + @NotNull + protected List resolveByPartitionIdx(List servers, QueueKey queueKey, int partition, + Map> responsibleServices) { if (servers == null || servers.isEmpty()) { - return null; + return Collections.emptyList(); } TenantId tenantId = queueKey.getTenantId(); @@ -637,15 +700,21 @@ public class HashPartitionService implements PartitionService { responsibleServices.put(profileId, responsible); } if (responsible.isEmpty()) { - return null; + return Collections.emptyList(); } servers = responsible; } int hash = hash(tenantId.getId()); - return servers.get(Math.abs((hash + partition) % servers.size())); + ServiceInfo server = servers.get(Math.abs((hash + partition) % servers.size())); + return server != null ? List.of(server) : Collections.emptyList(); + } else if (queueKey.getType() == ServiceType.EDQS) { + List> sets = servers.stream().collect(Collectors.groupingBy(ServiceInfo::getLabel)) + .entrySet().stream().sorted(Map.Entry.comparingByKey()).map(Map.Entry::getValue).toList(); + return sets.get(partition % sets.size()); } else { - return servers.get(partition % servers.size()); + ServiceInfo server = servers.get(partition % servers.size()); + return server != null ? List.of(server) : Collections.emptyList(); } } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/PartitionService.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/PartitionService.java index 5fe97ae972..7abd68e25f 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/PartitionService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/PartitionService.java @@ -41,8 +41,12 @@ public interface PartitionService { boolean isMyPartition(ServiceType serviceType, TenantId tenantId, EntityId entityId); + boolean isSystemPartitionMine(ServiceType serviceType); + List getMyPartitions(QueueKey queueKey); + String getTopic(QueueKey queueKey); + /** * Received from the Discovery service when network topology is changed. * @param currentService - current service information {@link org.thingsboard.server.gen.transport.TransportProtos.ServiceInfo} @@ -61,8 +65,6 @@ public interface PartitionService { Set getOtherServices(ServiceType serviceType); - int resolvePartitionIndex(UUID entityId, int partitions); - void evictTenantInfo(TenantId tenantId); int countTransportsByType(String type); @@ -75,4 +77,6 @@ public interface PartitionService { boolean isManagedByCurrentService(TenantId tenantId); + int resolvePartitionIndex(UUID entityId, int partitions); + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/QueueKey.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/QueueKey.java index b06ba904c8..1709003ada 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/QueueKey.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/QueueKey.java @@ -56,6 +56,12 @@ public class QueueKey { this.tenantId = TenantId.SYS_TENANT_ID; } + public QueueKey(ServiceType type, String queueName) { + this.type = type; + this.queueName = queueName; + this.tenantId = TenantId.SYS_TENANT_ID; + } + @Override public String toString() { return "QK(" + queueName + "," + type + "," + diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/TopicService.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/TopicService.java index f3b52cf23f..5992083d85 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/TopicService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/TopicService.java @@ -32,9 +32,28 @@ public class TopicService { @Value("${queue.prefix:}") private String prefix; + @Value("${queue.core.notifications-topic:tb_core.notifications}") + private String tbCoreNotificationsTopic; + + @Value("${queue.rule-engine.notifications-topic:tb_rule_engine.notifications}") + private String tbRuleEngineNotificationsTopic; + + @Value("${queue.transport.notifications-topics:tb_transport.notifications}") + private String tbTransportNotificationsTopic; + + @Value("${queue.edge.notifications-topic:tb_edge.notifications}") + private String tbEdgeNotificationsTopic; + + @Value("${queue.edge.event-notifications-topic:tb_edge_event.notifications}") + private String tbEdgeEventNotificationsTopic; + + @Value("${queue.calculated_fields.notifications-topic:calculated_field.notifications}") + private String tbCalculatedFieldNotificationsTopic; + private final ConcurrentMap tbCoreNotificationTopics = new ConcurrentHashMap<>(); private final ConcurrentMap tbRuleEngineNotificationTopics = new ConcurrentHashMap<>(); private final ConcurrentMap tbEdgeNotificationTopics = new ConcurrentHashMap<>(); + private final ConcurrentMap tbCalculatedFieldNotificationTopics = new ConcurrentHashMap<>(); private final ConcurrentReferenceHashMap tbEdgeEventsNotificationTopics = new ConcurrentReferenceHashMap<>(); /** @@ -47,19 +66,32 @@ public class TopicService { public TopicPartitionInfo getNotificationsTopic(ServiceType serviceType, String serviceId) { return switch (serviceType) { case TB_CORE -> tbCoreNotificationTopics.computeIfAbsent(serviceId, - id -> buildNotificationsTopicPartitionInfo(serviceType, serviceId)); + id -> buildNotificationsTopicPartitionInfo(tbCoreNotificationsTopic, serviceId)); case TB_RULE_ENGINE -> tbRuleEngineNotificationTopics.computeIfAbsent(serviceId, - id -> buildNotificationsTopicPartitionInfo(serviceType, serviceId)); - default -> buildNotificationsTopicPartitionInfo(serviceType, serviceId); + id -> buildNotificationsTopicPartitionInfo(tbRuleEngineNotificationsTopic, serviceId)); + case TB_TRANSPORT -> buildNotificationsTopicPartitionInfo(tbTransportNotificationsTopic, serviceId); + default -> throw new IllegalStateException("Unexpected service type: " + serviceType); }; } + private TopicPartitionInfo buildNotificationsTopicPartitionInfo(String topic, String serviceId) { + return buildTopicPartitionInfo(buildNotificationTopicName(topic, serviceId), null, null, false); + } + + public TopicPartitionInfo buildTopicPartitionInfo(String topic, TenantId tenantId, Integer partition, boolean myPartition) { + return new TopicPartitionInfo(buildTopicName(topic), tenantId, partition, myPartition); + } + public TopicPartitionInfo getEdgeNotificationsTopic(String serviceId) { return tbEdgeNotificationTopics.computeIfAbsent(serviceId, id -> buildEdgeNotificationsTopicPartitionInfo(serviceId)); } private TopicPartitionInfo buildEdgeNotificationsTopicPartitionInfo(String serviceId) { - return buildTopicPartitionInfo("tb_edge.notifications." + serviceId, null, null, false); + return buildTopicPartitionInfo(buildNotificationTopicName(tbEdgeNotificationsTopic, serviceId), null, null, false); + } + + public TopicPartitionInfo getCalculatedFieldNotificationsTopic(String serviceId) { + return tbCalculatedFieldNotificationTopics.computeIfAbsent(serviceId, id -> buildNotificationsTopicPartitionInfo(tbCalculatedFieldNotificationsTopic, serviceId)); } public TopicPartitionInfo getEdgeEventNotificationsTopic(TenantId tenantId, EdgeId edgeId) { @@ -67,21 +99,17 @@ public class TopicService { } public TopicPartitionInfo buildEdgeEventNotificationsTopicPartitionInfo(TenantId tenantId, EdgeId edgeId) { - return buildTopicPartitionInfo("tb_edge_event.notifications." + tenantId + "." + edgeId, null, null, false); - } - - private TopicPartitionInfo buildNotificationsTopicPartitionInfo(ServiceType serviceType, String serviceId) { - return buildTopicPartitionInfo(serviceType.name().toLowerCase() + ".notifications." + serviceId, null, null, false); - } - - public TopicPartitionInfo buildTopicPartitionInfo(String topic, TenantId tenantId, Integer partition, boolean myPartition) { - return new TopicPartitionInfo(buildTopicName(topic), tenantId, partition, myPartition); + return buildTopicPartitionInfo(tbEdgeEventNotificationsTopic + "." + tenantId + "." + edgeId, null, null, false); } public String buildTopicName(String topic) { return prefix.isBlank() ? topic : prefix + "." + topic; } + private String buildNotificationTopicName(String topic, String serviceId) { + return topic + "." + serviceId; + } + public String buildConsumerGroupId(String servicePrefix, TenantId tenantId, String queueName, Integer partitionId) { return this.buildTopicName( servicePrefix + queueName diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java index 138e963188..f7a4d2abf6 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java @@ -19,6 +19,7 @@ import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.ProtocolStringList; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; +import lombok.Getter; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.curator.framework.CuratorFramework; @@ -68,6 +69,7 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi private Integer zkConnectionTimeout; @Value("${zk.session_timeout_ms}") private Integer zkSessionTimeout; + @Getter @Value("${zk.zk_dir}") private String zkDir; @Value("${zk.recalculate_delay:0}") @@ -80,6 +82,7 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi private final PartitionService partitionService; private ScheduledExecutorService zkExecutorService; + @Getter private CuratorFramework client; private PathChildrenCache cache; private String nodePath; @@ -140,6 +143,7 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi } else { log.info("Received application ready event. Starting current ZK node."); } + subscribeToEvents(); if (client.getState() != CuratorFrameworkState.STARTED) { log.debug("Ignoring application ready event, ZK client is not started, ZK client state [{}]", client.getState()); return; @@ -209,6 +213,7 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi try { destroyZkClient(); initZkClient(); + subscribeToEvents(); publishCurrentServer(); } catch (Exception e) { log.error("Failed to reconnect to ZK: {}", e.getMessage(), e); @@ -224,7 +229,6 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi client.start(); client.blockUntilConnected(); cache = new PathChildrenCache(client, zkNodesDir, true); - cache.getListenable().addListener(this); cache.start(); stopped = false; log.info("ZK client connected"); @@ -236,6 +240,10 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi } } + private void subscribeToEvents() { + cache.getListenable().addListener(this); + } + private void unpublishCurrentServer() { try { if (nodePath != null) { @@ -243,25 +251,21 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi } } catch (Exception e) { log.error("Failed to delete ZK node {}", nodePath, e); - throw new RuntimeException(e); } } private void destroyZkClient() { stopped = true; - try { - unpublishCurrentServer(); - } catch (Exception e) { - } + unpublishCurrentServer(); CloseableUtils.closeQuietly(cache); CloseableUtils.closeQuietly(client); log.info("ZK client disconnected"); } @PreDestroy - public void destroy() { - destroyZkClient(); + private void destroy() { zkExecutorService.shutdownNow(); + destroyZkClient(); log.info("Stopped discovery service"); } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/event/PartitionChangeEvent.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/event/PartitionChangeEvent.java index 530773a3c6..32537edba5 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/event/PartitionChangeEvent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/event/PartitionChangeEvent.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.queue.discovery.QueueKey; import java.io.Serial; +import java.util.Collection; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -36,12 +37,17 @@ public class PartitionChangeEvent extends TbApplicationEvent { @Getter private final ServiceType serviceType; @Getter - private final Map> partitionsMap; + private final Map> newPartitions; + @Getter + private final Map> oldPartitions; - public PartitionChangeEvent(Object source, ServiceType serviceType, Map> partitionsMap) { + public PartitionChangeEvent(Object source, ServiceType serviceType, + Map> newPartitions, + Map> oldPartitions) { super(source); this.serviceType = serviceType; - this.partitionsMap = partitionsMap; + this.newPartitions = newPartitions; + this.oldPartitions = oldPartitions; } public Set getCorePartitions() { @@ -52,11 +58,20 @@ public class PartitionChangeEvent extends TbApplicationEvent { return getPartitionsByServiceTypeAndQueueName(ServiceType.TB_CORE, DataConstants.EDGE_QUEUE_NAME); } - private Set getPartitionsByServiceTypeAndQueueName(ServiceType serviceType, String queueName) { - return partitionsMap.entrySet() + public Set getPartitions() { + return newPartitions.values().stream().flatMap(Collection::stream).collect(Collectors.toSet()); + } + + public Set getCfPartitions() { + return getPartitionsByServiceTypeAndQueueName(ServiceType.TB_RULE_ENGINE, DataConstants.CF_QUEUE_NAME); + } + + public Set getPartitionsByServiceTypeAndQueueName(ServiceType serviceType, String queueName) { + return newPartitions.entrySet() .stream() .filter(entry -> serviceType.equals(entry.getKey().getType()) && queueName.equals(entry.getKey().getQueueName())) .flatMap(entry -> entry.getValue().stream()) .collect(Collectors.toSet()); } + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsComponent.java new file mode 100644 index 0000000000..c3658d098a --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsComponent.java @@ -0,0 +1,28 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.edqs; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@ConditionalOnExpression("'${queue.edqs.sync.enabled:true}'=='true' && ('${service.type:null}'=='edqs' || " + + "(('${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core') && " + + "'${queue.edqs.mode:null}'=='local'))") +public @interface EdqsComponent { +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsConfig.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsConfig.java new file mode 100644 index 0000000000..e4e1e81815 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsConfig.java @@ -0,0 +1,55 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.edqs; + +import lombok.Data; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +@Data +public class EdqsConfig { + + @Value("${queue.edqs.partitions:12}") + private int partitions; + @Value("${service.edqs.label:}") + private String label; + @Value("#{'${queue.edqs.partitioning_strategy:tenant}'.toUpperCase()}") + private EdqsPartitioningStrategy partitioningStrategy; + + @Value("${queue.edqs.requests_topic:edqs.requests}") + private String requestsTopic; + @Value("${queue.edqs.responses_topic:edqs.responses}") + private String responsesTopic; + @Value("${queue.edqs.poll_interval:125}") + private long pollInterval; + @Value("${queue.edqs.max_pending_requests:10000}") + private int maxPendingRequests; + @Value("${queue.edqs.max_request_timeout:20000}") + private int maxRequestTimeout; + + public String getLabel() { + if (partitioningStrategy == EdqsPartitioningStrategy.NONE) { + label = "all"; // single set for all instances, so that each instance has all partitions + } + return label; + } + + public enum EdqsPartitioningStrategy { + TENANT, NONE + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsQueue.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsQueue.java new file mode 100644 index 0000000000..d859b50994 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsQueue.java @@ -0,0 +1,36 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.edqs; + +import lombok.Getter; + +@Getter +public enum EdqsQueue { + + EVENTS("edqs.events", false, false), + STATE("edqs.state", true, true); + + private final String topic; + private final boolean readFromBeginning; + private final boolean stopWhenRead; + + EdqsQueue(String topic, boolean readFromBeginning, boolean stopWhenRead) { + this.topic = topic; + this.readFromBeginning = readFromBeginning; + this.stopWhenRead = stopWhenRead; + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsQueueFactory.java new file mode 100644 index 0000000000..b5541c740b --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsQueueFactory.java @@ -0,0 +1,38 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.edqs; + +import org.thingsboard.server.gen.transport.TransportProtos.FromEdqsMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; +import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.TbQueueResponseTemplate; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; + +public interface EdqsQueueFactory { + + TbQueueConsumer> createEdqsMsgConsumer(EdqsQueue queue); + + TbQueueConsumer> createEdqsMsgConsumer(EdqsQueue queue, String group); + + TbQueueProducer> createEdqsMsgProducer(EdqsQueue queue); + + TbQueueResponseTemplate, TbProtoQueueMsg> createEdqsResponseTemplate(); + + TbQueueAdmin getEdqsQueueAdmin(); + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/InMemoryEdqsComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/InMemoryEdqsComponent.java new file mode 100644 index 0000000000..e414d24fd9 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/InMemoryEdqsComponent.java @@ -0,0 +1,26 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.edqs; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@ConditionalOnExpression("'${queue.edqs.sync.enabled:true}'=='true' && '${service.type:null}'=='monolith' && '${queue.edqs.mode:null}'=='local' && '${queue.type:null}'=='in-memory'") +public @interface InMemoryEdqsComponent { +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/InMemoryEdqsQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/InMemoryEdqsQueueFactory.java new file mode 100644 index 0000000000..0801399c14 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/InMemoryEdqsQueueFactory.java @@ -0,0 +1,86 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.edqs; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.common.stats.StatsType; +import org.thingsboard.server.gen.transport.TransportProtos.FromEdqsMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; +import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.TbQueueResponseTemplate; +import org.thingsboard.server.queue.common.DefaultTbQueueResponseTemplate; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.memory.InMemoryStorage; +import org.thingsboard.server.queue.memory.InMemoryTbQueueConsumer; +import org.thingsboard.server.queue.memory.InMemoryTbQueueProducer; + +@Component +@InMemoryEdqsComponent +@RequiredArgsConstructor +public class InMemoryEdqsQueueFactory implements EdqsQueueFactory { + + private final InMemoryStorage storage; + private final EdqsConfig edqsConfig; + private final StatsFactory statsFactory; + private final TbQueueAdmin queueAdmin; + + @Override + public TbQueueConsumer> createEdqsMsgConsumer(EdqsQueue queue) { + if (queue == EdqsQueue.STATE) { + throw new UnsupportedOperationException(); + } + return new InMemoryTbQueueConsumer<>(storage, queue.getTopic()); + } + + @Override + public TbQueueConsumer> createEdqsMsgConsumer(EdqsQueue queue, String group) { + return createEdqsMsgConsumer(queue); + } + + @Override + public TbQueueProducer> createEdqsMsgProducer(EdqsQueue queue) { + if (queue == EdqsQueue.STATE) { + throw new UnsupportedOperationException(); + } + return new InMemoryTbQueueProducer<>(storage, queue.getTopic()); + } + + @Override + public TbQueueResponseTemplate, TbProtoQueueMsg> createEdqsResponseTemplate() { + TbQueueConsumer> requestConsumer = new InMemoryTbQueueConsumer<>(storage, edqsConfig.getRequestsTopic()); + TbQueueProducer> responseProducer = new InMemoryTbQueueProducer<>(storage, edqsConfig.getResponsesTopic()); + return DefaultTbQueueResponseTemplate., TbProtoQueueMsg>builder() + .requestTemplate(requestConsumer) + .responseTemplate(responseProducer) + .maxPendingRequests(edqsConfig.getMaxPendingRequests()) + .requestTimeout(edqsConfig.getMaxRequestTimeout()) + .pollInterval(edqsConfig.getPollInterval()) + .stats(statsFactory.createMessagesStats(StatsType.EDQS.getName())) + .executor(ThingsBoardExecutors.newWorkStealingPool(5, "edqs")) + .build(); + } + + @Override + public TbQueueAdmin getEdqsQueueAdmin() { + return queueAdmin; + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/KafkaEdqsComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/KafkaEdqsComponent.java new file mode 100644 index 0000000000..3a2b282724 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/KafkaEdqsComponent.java @@ -0,0 +1,28 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.edqs; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@ConditionalOnExpression("'${queue.edqs.sync.enabled:true}'=='true' && ('${service.type:null}'=='edqs' || " + + "(('${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core') && " + + "'${queue.edqs.mode:null}'=='local' && '${queue.type:null}'=='kafka'))") +public @interface KafkaEdqsComponent { +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/KafkaEdqsQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/KafkaEdqsQueueFactory.java new file mode 100644 index 0000000000..e985696040 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/KafkaEdqsQueueFactory.java @@ -0,0 +1,135 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.edqs; + +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.common.stats.StatsType; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.FromEdqsMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; +import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.TbQueueResponseTemplate; +import org.thingsboard.server.queue.common.DefaultTbQueueResponseTemplate; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.discovery.TopicService; +import org.thingsboard.server.queue.kafka.TbKafkaAdmin; +import org.thingsboard.server.queue.kafka.TbKafkaConsumerStatsService; +import org.thingsboard.server.queue.kafka.TbKafkaConsumerTemplate; +import org.thingsboard.server.queue.kafka.TbKafkaProducerTemplate; +import org.thingsboard.server.queue.kafka.TbKafkaSettings; +import org.thingsboard.server.queue.kafka.TbKafkaTopicConfigs; + +import java.util.concurrent.atomic.AtomicInteger; + +@Component +@KafkaEdqsComponent +public class KafkaEdqsQueueFactory implements EdqsQueueFactory { + + private final TbKafkaSettings kafkaSettings; + private final TbKafkaAdmin edqsEventsAdmin; + private final TbKafkaAdmin edqsRequestsAdmin; + private final TbKafkaAdmin edqsStateAdmin; + private final EdqsConfig edqsConfig; + private final TbServiceInfoProvider serviceInfoProvider; + private final TbKafkaConsumerStatsService consumerStatsService; + private final TopicService topicService; + private final StatsFactory statsFactory; + + private final AtomicInteger consumerCounter = new AtomicInteger(); + + public KafkaEdqsQueueFactory(TbKafkaSettings kafkaSettings, TbKafkaTopicConfigs topicConfigs, + EdqsConfig edqsConfig, TbServiceInfoProvider serviceInfoProvider, + TbKafkaConsumerStatsService consumerStatsService, TopicService topicService, + StatsFactory statsFactory) { + this.edqsEventsAdmin = new TbKafkaAdmin(kafkaSettings, topicConfigs.getEdqsEventsConfigs()); + this.edqsRequestsAdmin = new TbKafkaAdmin(kafkaSettings, topicConfigs.getEdqsRequestsConfigs()); + this.edqsStateAdmin = new TbKafkaAdmin(kafkaSettings, topicConfigs.getEdqsStateConfigs()); + this.kafkaSettings = kafkaSettings; + this.edqsConfig = edqsConfig; + this.serviceInfoProvider = serviceInfoProvider; + this.consumerStatsService = consumerStatsService; + this.topicService = topicService; + this.statsFactory = statsFactory; + } + + @Override + public TbQueueConsumer> createEdqsMsgConsumer(EdqsQueue queue) { + String consumerGroup = "edqs-" + queue.name().toLowerCase() + "-consumer-group-" + serviceInfoProvider.getServiceId(); + return createEdqsMsgConsumer(queue, consumerGroup); + } + + @Override + public TbQueueConsumer> createEdqsMsgConsumer(EdqsQueue queue, String group) { + return TbKafkaConsumerTemplate.>builder() + .settings(kafkaSettings) + .topic(topicService.buildTopicName(queue.getTopic())) + .readFromBeginning(queue.isReadFromBeginning()) + .stopWhenRead(queue.isStopWhenRead()) + .clientId("edqs-" + queue.name().toLowerCase() + "-" + consumerCounter.getAndIncrement() + "-consumer-" + serviceInfoProvider.getServiceId()) + .groupId(topicService.buildTopicName(group)) + .decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), ToEdqsMsg.parseFrom(msg.getData()), msg.getHeaders())) + .admin(queue == EdqsQueue.STATE ? edqsStateAdmin : edqsEventsAdmin) + .statsService(consumerStatsService) + .build(); + } + + @Override + public TbQueueProducer> createEdqsMsgProducer(EdqsQueue queue) { + return TbKafkaProducerTemplate.>builder() + .clientId("edqs-" + queue.name().toLowerCase() + "-producer-" + serviceInfoProvider.getServiceId()) + .settings(kafkaSettings) + .admin(queue == EdqsQueue.STATE ? edqsStateAdmin : edqsEventsAdmin) + .build(); + } + + @Override + public TbQueueResponseTemplate, TbProtoQueueMsg> createEdqsResponseTemplate() { + String requestsConsumerGroup = "edqs-requests-consumer-group-" + edqsConfig.getLabel(); + var requestConsumer = TbKafkaConsumerTemplate.>builder() + .settings(kafkaSettings) + .topic(topicService.buildTopicName(edqsConfig.getRequestsTopic())) + .clientId("edqs-requests-consumer-" + serviceInfoProvider.getServiceId()) + .groupId(topicService.buildTopicName(requestsConsumerGroup)) + .decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), TransportProtos.ToEdqsMsg.parseFrom(msg.getData()), msg.getHeaders())) + .admin(edqsRequestsAdmin) + .statsService(consumerStatsService); + var responseProducer = TbKafkaProducerTemplate.>builder() + .settings(kafkaSettings) + .clientId("edqs-response-producer-" + serviceInfoProvider.getServiceId()) + .defaultTopic(topicService.buildTopicName(edqsConfig.getResponsesTopic())) + .admin(edqsRequestsAdmin); + return DefaultTbQueueResponseTemplate., TbProtoQueueMsg>builder() + .requestTemplate(requestConsumer.build()) + .responseTemplate(responseProducer.build()) + .maxPendingRequests(edqsConfig.getMaxPendingRequests()) + .requestTimeout(edqsConfig.getMaxRequestTimeout()) + .pollInterval(edqsConfig.getPollInterval()) + .stats(statsFactory.createMessagesStats(StatsType.EDQS.getName())) + .executor(ThingsBoardExecutors.newWorkStealingPool(5, "edqs")) + .build(); + } + + @Override + public TbQueueAdmin getEdqsQueueAdmin() { + return edqsEventsAdmin; + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/environment/DistributedLock.java b/common/queue/src/main/java/org/thingsboard/server/queue/environment/DistributedLock.java new file mode 100644 index 0000000000..e5553078e4 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/environment/DistributedLock.java @@ -0,0 +1,24 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.environment; + +public interface DistributedLock { + + void lock(); + + void unlock(); + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/environment/DistributedLockService.java b/common/queue/src/main/java/org/thingsboard/server/queue/environment/DistributedLockService.java new file mode 100644 index 0000000000..3fe5d7b21b --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/environment/DistributedLockService.java @@ -0,0 +1,22 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.environment; + +public interface DistributedLockService { + + DistributedLock getLock(String key); + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/environment/DummyDistributedLockService.java b/common/queue/src/main/java/org/thingsboard/server/queue/environment/DummyDistributedLockService.java new file mode 100644 index 0000000000..96483c1f5a --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/environment/DummyDistributedLockService.java @@ -0,0 +1,53 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.environment; + +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.util.concurrent.locks.ReentrantLock; + +@Service +@ConditionalOnProperty(prefix = "zk", value = "enabled", havingValue = "false", matchIfMissing = true) +public class DummyDistributedLockService implements DistributedLockService { + + @Override + public DistributedLock getLock(String key) { + return new DummyDistributedLock(); + } + + @RequiredArgsConstructor + private static class DummyDistributedLock implements DistributedLock { + + private final ReentrantLock lock = new ReentrantLock(); + + @SneakyThrows + @Override + public void lock() { + lock.lock(); + } + + @SneakyThrows + @Override + public void unlock() { + lock.unlock(); + } + + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/environment/ZkDistributedLockService.java b/common/queue/src/main/java/org/thingsboard/server/queue/environment/ZkDistributedLockService.java new file mode 100644 index 0000000000..65405c3d7e --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/environment/ZkDistributedLockService.java @@ -0,0 +1,62 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.environment; + +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.curator.framework.recipes.locks.InterProcessLock; +import org.apache.curator.framework.recipes.locks.InterProcessMutex; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.thingsboard.server.queue.discovery.ZkDiscoveryService; + +@Service +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "zk", value = "enabled", havingValue = "true") +@Slf4j +public class ZkDistributedLockService implements DistributedLockService { + + private final ZkDiscoveryService zkDiscoveryService; + + @Override + public DistributedLock getLock(String key) { + return new ZkDistributedLock(key); + } + + @RequiredArgsConstructor + private class ZkDistributedLock implements DistributedLock { + + private final InterProcessLock interProcessLock; + + public ZkDistributedLock(String key) { + this.interProcessLock = new InterProcessMutex(zkDiscoveryService.getClient(), zkDiscoveryService.getZkDir() + "/locks/" + key); + } + + @SneakyThrows + @Override + public void lock() { + interProcessLock.acquire(); + } + + @SneakyThrows + @Override + public void unlock() { + interProcessLock.release(); + } + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/KafkaTbQueueMsg.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/KafkaTbQueueMsg.java index 99bd2c8401..ffd4321060 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/KafkaTbQueueMsg.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/KafkaTbQueueMsg.java @@ -23,12 +23,19 @@ import org.thingsboard.server.queue.common.DefaultTbQueueMsgHeaders; import java.util.UUID; public class KafkaTbQueueMsg implements TbQueueMsg { + + private static final int UUID_LENGTH = 36; + private final UUID key; private final TbQueueMsgHeaders headers; private final byte[] data; public KafkaTbQueueMsg(ConsumerRecord record) { - this.key = UUID.fromString(record.key()); + if (record.key().length() <= UUID_LENGTH) { + this.key = UUID.fromString(record.key()); + } else { + this.key = UUID.randomUUID(); + } TbQueueMsgHeaders headers = new DefaultTbQueueMsgHeaders(); record.headers().forEach(header -> { headers.put(header.key(), header.value()); diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaAdmin.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaAdmin.java index e774bd74a7..4ae744be67 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaAdmin.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaAdmin.java @@ -57,7 +57,6 @@ public class TbKafkaAdmin implements TbQueueAdmin { String numPartitionsStr = topicConfigs.get(TbKafkaTopicConfigs.NUM_PARTITIONS_SETTING); if (numPartitionsStr != null) { numPartitions = Integer.parseInt(numPartitionsStr); - topicConfigs.remove("partitions"); } else { numPartitions = 1; } @@ -71,7 +70,9 @@ public class TbKafkaAdmin implements TbQueueAdmin { return; } try { - NewTopic newTopic = new NewTopic(topic, numPartitions, replicationFactor).configs(PropertyUtils.getProps(topicConfigs, properties)); + Map configs = PropertyUtils.getProps(topicConfigs, properties); + configs.remove(TbKafkaTopicConfigs.NUM_PARTITIONS_SETTING); + NewTopic newTopic = new NewTopic(topic, numPartitions, replicationFactor).configs(configs); createTopic(newTopic).values().get(topic).get(); topics.add(topic); } catch (ExecutionException ee) { @@ -90,7 +91,7 @@ public class TbKafkaAdmin implements TbQueueAdmin { @Override public void deleteTopic(String topic) { Set topics = getTopics(); - if (topics.contains(topic)) { + if (topics.remove(topic)) { settings.getAdminClient().deleteTopics(Collections.singletonList(topic)); } else { try { @@ -188,6 +189,9 @@ public class TbKafkaAdmin implements TbQueueAdmin { public boolean isTopicEmpty(String topic) { try { + if (!getTopics().contains(topic)) { + return true; + } TopicDescription topicDescription = settings.getAdminClient().describeTopics(Collections.singletonList(topic)).topicNameValues().get(topic).get(); List partitions = topicDescription.partitions().stream().map(partitionInfo -> new TopicPartition(topic, partitionInfo.partition())).toList(); diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java index 3879d2cffd..9d1088e188 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java @@ -26,15 +26,10 @@ import org.apache.kafka.clients.consumer.ConsumerConfig; import org.apache.kafka.clients.consumer.KafkaConsumer; import org.apache.kafka.clients.consumer.OffsetAndMetadata; import org.apache.kafka.common.TopicPartition; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.common.data.StringUtils; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.msg.queue.ServiceType; -import org.thingsboard.server.queue.discovery.PartitionService; import java.time.Duration; import java.util.ArrayList; @@ -56,10 +51,6 @@ public class TbKafkaConsumerStatsService { private final TbKafkaSettings kafkaSettings; private final TbKafkaConsumerStatisticConfig statsConfig; - @Lazy - @Autowired - private PartitionService partitionService; - private Consumer consumer; private ScheduledExecutorService statsPrintScheduler; @@ -111,9 +102,7 @@ public class TbKafkaConsumerStatsService { } private boolean isStatsPrintRequired() { - boolean isMyRuleEnginePartition = partitionService.isMyPartition(ServiceType.TB_RULE_ENGINE, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID); - boolean isMyCorePartition = partitionService.isMyPartition(ServiceType.TB_CORE, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID); - return log.isInfoEnabled() && (isMyRuleEnginePartition || isMyCorePartition); + return log.isInfoEnabled(); } private List getTopicsStatsWithLag(Map groupOffsets, Map endOffsets) { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerTemplate.java index 02bc408c6e..4bd3bf0fe6 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerTemplate.java @@ -18,10 +18,13 @@ package org.thingsboard.server.queue.kafka; import lombok.Builder; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRebalanceListener; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.apache.kafka.clients.consumer.ConsumerRecords; import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.common.TopicPartition; import org.springframework.util.StopWatch; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.queue.TbQueueAdmin; import org.thingsboard.server.queue.TbQueueMsg; import org.thingsboard.server.queue.common.AbstractTbQueueConsumerTemplate; @@ -29,9 +32,14 @@ import org.thingsboard.server.queue.common.AbstractTbQueueConsumerTemplate; import java.io.IOException; import java.time.Duration; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Properties; +import java.util.Set; +import java.util.stream.Collectors; /** * Created by ashvayka on 24.09.18. @@ -46,10 +54,16 @@ public class TbKafkaConsumerTemplate extends AbstractTbQue private final TbKafkaConsumerStatsService statsService; private final String groupId; + private final boolean readFromBeginning; // reset offset to beginning + private final boolean stopWhenRead; // stop consuming when reached end offset remembered on start + private int readCount; + private Map endOffsets; // needed if stopWhenRead is true + @Builder private TbKafkaConsumerTemplate(TbKafkaSettings settings, TbKafkaDecoder decoder, String clientId, String groupId, String topic, - TbQueueAdmin admin, TbKafkaConsumerStatsService statsService) { + TbQueueAdmin admin, TbKafkaConsumerStatsService statsService, + boolean readFromBeginning, boolean stopWhenRead) { super(topic); Properties props = settings.toConsumerProps(topic); props.put(ConsumerConfig.CLIENT_ID_CONFIG, clientId); @@ -67,13 +81,55 @@ public class TbKafkaConsumerTemplate extends AbstractTbQue this.admin = admin; this.consumer = new KafkaConsumer<>(props); this.decoder = decoder; + this.readFromBeginning = readFromBeginning; + this.stopWhenRead = stopWhenRead; } @Override - protected void doSubscribe(List topicNames) { - if (!topicNames.isEmpty()) { - topicNames.forEach(admin::createTopicIfNotExists); - consumer.subscribe(topicNames); + protected void doSubscribe(Set partitions) { + Map> topics; + if (partitions == null) { + topics = Collections.emptyMap(); + } else { + topics = new HashMap<>(); + partitions.forEach(tpi -> { + if (tpi.isUseInternalPartition()) { + topics.computeIfAbsent(tpi.getFullTopicName(), t -> new ArrayList<>()).add(tpi.getPartition().get()); + } else { + topics.put(tpi.getFullTopicName(), null); + } + }); + } + if (!topics.isEmpty()) { + topics.keySet().forEach(admin::createTopicIfNotExists); + List toSubscribe = new ArrayList<>(); + topics.forEach((topic, kafkaPartitions) -> { + if (kafkaPartitions == null) { + toSubscribe.add(topic); + } else { + List topicPartitions = kafkaPartitions.stream() + .map(partition -> new TopicPartition(topic, partition)) + .toList(); + consumer.assign(topicPartitions); + onPartitionsAssigned(topicPartitions); + } + }); + if (!toSubscribe.isEmpty()) { + if (readFromBeginning || stopWhenRead) { + consumer.subscribe(toSubscribe, new ConsumerRebalanceListener() { + @Override + public void onPartitionsRevoked(Collection partitions) {} + + @Override + public void onPartitionsAssigned(Collection partitions) { + log.debug("Handling onPartitionsAssigned {}", partitions); + TbKafkaConsumerTemplate.this.onPartitionsAssigned(partitions); + } + }); + } else { + consumer.subscribe(toSubscribe); + } + } } else { log.info("unsubscribe due to empty topic list"); consumer.unsubscribe(); @@ -92,12 +148,43 @@ public class TbKafkaConsumerTemplate extends AbstractTbQue stopWatch.stop(); log.trace("poll topic {} took {}ms", getTopic(), stopWatch.getTotalTimeMillis()); + List> recordList; if (records.isEmpty()) { - return Collections.emptyList(); + recordList = Collections.emptyList(); } else { - List> recordList = new ArrayList<>(256); - records.forEach(recordList::add); - return recordList; + recordList = new ArrayList<>(256); + records.forEach(record -> { + recordList.add(record); + if (stopWhenRead && endOffsets != null) { + readCount++; + int partition = record.partition(); + Long endOffset = endOffsets.get(partition); + if (endOffset == null) { + log.debug("End offset not found for {} [{}]", record.topic(), partition); + return; + } + log.trace("[{}-{}] Got record offset {}, expected end offset: {}", record.topic(), partition, record.offset(), endOffset - 1); + if (record.offset() >= endOffset - 1) { + endOffsets.remove(partition); + } + } + }); + } + if (stopWhenRead && endOffsets != null && endOffsets.isEmpty()) { + log.info("Finished reading {}, processed {} messages", partitions, readCount); + stop(); + } + return recordList; + } + + private void onPartitionsAssigned(Collection partitions) { + if (readFromBeginning) { + consumer.seekToBeginning(partitions); + } + if (stopWhenRead) { + endOffsets = consumer.endOffsets(partitions).entrySet().stream() + .filter(entry -> entry.getValue() > 0) + .collect(Collectors.toMap(entry -> entry.getKey().partition(), Map.Entry::getValue)); } } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaProducerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaProducerTemplate.java index 6a303fc05a..cac6f2ea1e 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaProducerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaProducerTemplate.java @@ -54,7 +54,7 @@ public class TbKafkaProducerTemplate implements TbQueuePro private final TbQueueAdmin admin; - private final Set topics; + private final Set topics; @Getter private final String clientId; @@ -97,16 +97,21 @@ public class TbKafkaProducerTemplate implements TbQueuePro @Override public void send(TopicPartitionInfo tpi, T msg, TbQueueCallback callback) { + send(tpi, msg.getKey().toString(), msg, callback); + } + + public void send(TopicPartitionInfo tpi, String key, T msg, TbQueueCallback callback) { try { - createTopicIfNotExist(tpi); - String key = msg.getKey().toString(); + String topic = tpi.getFullTopicName(); + createTopicIfNotExist(topic); byte[] data = msg.getData(); ProducerRecord record; List

headers = msg.getHeaders().getData().entrySet().stream().map(e -> new RecordHeader(e.getKey(), e.getValue())).collect(Collectors.toList()); if (log.isDebugEnabled()) { addAnalyticHeaders(headers); } - record = new ProducerRecord<>(tpi.getFullTopicName(), null, key, data, headers); + Integer partition = tpi.isUseInternalPartition() ? tpi.getPartition().orElse(null) : null; + record = new ProducerRecord<>(topic, partition, key, data, headers); producer.send(record, (metadata, exception) -> { if (exception == null) { if (callback != null) { @@ -116,7 +121,7 @@ public class TbKafkaProducerTemplate implements TbQueuePro if (callback != null) { callback.onFailure(exception); } else { - log.warn("Producer template failure: {}", exception.getMessage(), exception); + log.warn("Producer template failure", exception); } } }); @@ -130,12 +135,12 @@ public class TbKafkaProducerTemplate implements TbQueuePro } } - private void createTopicIfNotExist(TopicPartitionInfo tpi) { - if (topics.contains(tpi)) { + private void createTopicIfNotExist(String topic) { + if (topics.contains(topic)) { return; } - admin.createTopicIfNotExists(tpi.getFullTopicName()); - topics.add(tpi); + admin.createTopicIfNotExists(topic); + topics.add(topic); } @Override @@ -144,4 +149,5 @@ public class TbKafkaProducerTemplate implements TbQueuePro producer.close(); } } + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaTopicConfigs.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaTopicConfigs.java index 5b40b50805..aebda5a5bc 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaTopicConfigs.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaTopicConfigs.java @@ -52,6 +52,16 @@ public class TbKafkaTopicConfigs { private String housekeeperProperties; @Value("${queue.kafka.topic-properties.housekeeper-reprocessing:}") private String housekeeperReprocessingProperties; + @Value("${queue.kafka.topic-properties.calculated-field:}") + private String calculatedFieldProperties; + @Value("${queue.kafka.topic-properties.calculated-field-state:}") + private String calculatedFieldStateProperties; + @Value("${queue.kafka.topic-properties.edqs-events:}") + private String edqsEventsProperties; + @Value("${queue.kafka.topic-properties.edqs-requests:}") + private String edqsRequestsProperties; + @Value("${queue.kafka.topic-properties.edqs-state:}") + private String edqsStateProperties; @Getter private Map coreConfigs; @@ -79,6 +89,16 @@ public class TbKafkaTopicConfigs { private Map edgeConfigs; @Getter private Map edgeEventConfigs; + @Getter + private Map calculatedFieldConfigs; + @Getter + private Map calculatedFieldStateConfigs; + @Getter + private Map edqsEventsConfigs; + @Getter + private Map edqsRequestsConfigs; + @Getter + private Map edqsStateConfigs; @PostConstruct private void init() { @@ -97,6 +117,11 @@ public class TbKafkaTopicConfigs { housekeeperReprocessingConfigs = PropertyUtils.getProps(housekeeperReprocessingProperties); edgeConfigs = PropertyUtils.getProps(edgeProperties); edgeEventConfigs = PropertyUtils.getProps(edgeEventProperties); + calculatedFieldConfigs = PropertyUtils.getProps(calculatedFieldProperties); + calculatedFieldStateConfigs = PropertyUtils.getProps(calculatedFieldStateProperties); + edqsEventsConfigs = PropertyUtils.getProps(edqsEventsProperties); + edqsRequestsConfigs = PropertyUtils.getProps(edqsRequestsProperties); + edqsStateConfigs = PropertyUtils.getProps(edqsStateProperties); } } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/EdqsClientQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/EdqsClientQueueFactory.java new file mode 100644 index 0000000000..95be49f82b --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/EdqsClientQueueFactory.java @@ -0,0 +1,31 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.provider; + +import org.thingsboard.server.gen.transport.TransportProtos.FromEdqsMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.TbQueueRequestTemplate; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.edqs.EdqsQueue; + +public interface EdqsClientQueueFactory { + + TbQueueProducer> createEdqsMsgProducer(EdqsQueue queue); + + TbQueueRequestTemplate, TbProtoQueueMsg> createEdqsRequestTemplate(); + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/InMemoryMonolithQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/InMemoryMonolithQueueFactory.java index e7ba2fed6d..fac2cf5d5e 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/InMemoryMonolithQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/InMemoryMonolithQueueFactory.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.queue.provider; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.scheduling.annotation.Scheduled; @@ -23,16 +24,24 @@ import org.thingsboard.server.common.data.queue.Queue; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.gen.js.JsInvokeProtos; import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; +import org.thingsboard.server.gen.transport.TransportProtos.FromEdqsMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; +import org.thingsboard.server.queue.TbQueueAdmin; import org.thingsboard.server.queue.TbQueueConsumer; import org.thingsboard.server.queue.TbQueueProducer; import org.thingsboard.server.queue.TbQueueRequestTemplate; +import org.thingsboard.server.queue.common.DefaultTbQueueRequestTemplate; import org.thingsboard.server.queue.common.TbProtoJsQueueMsg; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; import org.thingsboard.server.queue.discovery.TopicService; +import org.thingsboard.server.queue.edqs.EdqsConfig; +import org.thingsboard.server.queue.edqs.EdqsQueue; import org.thingsboard.server.queue.memory.InMemoryStorage; import org.thingsboard.server.queue.memory.InMemoryTbQueueConsumer; import org.thingsboard.server.queue.memory.InMemoryTbQueueProducer; +import org.thingsboard.server.queue.settings.TbQueueCalculatedFieldSettings; import org.thingsboard.server.queue.settings.TbQueueCoreSettings; import org.thingsboard.server.queue.settings.TbQueueEdgeSettings; import org.thingsboard.server.queue.settings.TbQueueRuleEngineSettings; @@ -43,37 +52,22 @@ import org.thingsboard.server.queue.settings.TbQueueVersionControlSettings; @Slf4j @Component @ConditionalOnExpression("'${queue.type:null}'=='in-memory' && '${service.type:null}'=='monolith'") +@RequiredArgsConstructor public class InMemoryMonolithQueueFactory implements TbCoreQueueFactory, TbRuleEngineQueueFactory, TbVersionControlQueueFactory { private final TopicService topicService; private final TbQueueCoreSettings coreSettings; private final TbServiceInfoProvider serviceInfoProvider; + private final TbQueueAdmin queueAdmin; private final TbQueueRuleEngineSettings ruleEngineSettings; private final TbQueueVersionControlSettings vcSettings; private final TbQueueTransportApiSettings transportApiSettings; private final TbQueueTransportNotificationSettings transportNotificationSettings; private final TbQueueEdgeSettings edgeSettings; + private final TbQueueCalculatedFieldSettings calculatedFieldSettings; + private final EdqsConfig edqsConfig; private final InMemoryStorage storage; - public InMemoryMonolithQueueFactory(TopicService topicService, TbQueueCoreSettings coreSettings, - TbQueueRuleEngineSettings ruleEngineSettings, - TbQueueVersionControlSettings vcSettings, - TbServiceInfoProvider serviceInfoProvider, - TbQueueTransportApiSettings transportApiSettings, - TbQueueTransportNotificationSettings transportNotificationSettings, - TbQueueEdgeSettings edgeSettings, - InMemoryStorage storage) { - this.topicService = topicService; - this.coreSettings = coreSettings; - this.vcSettings = vcSettings; - this.serviceInfoProvider = serviceInfoProvider; - this.ruleEngineSettings = ruleEngineSettings; - this.transportApiSettings = transportApiSettings; - this.transportNotificationSettings = transportNotificationSettings; - this.edgeSettings = edgeSettings; - this.storage = storage; - } - @Override public TbQueueProducer> createTransportNotificationsMsgProducer() { return new InMemoryTbQueueProducer<>(storage, topicService.buildTopicName(transportNotificationSettings.getNotificationsTopic())); @@ -139,6 +133,36 @@ public class InMemoryMonolithQueueFactory implements TbCoreQueueFactory, TbRuleE return null; } + @Override + public TbQueueConsumer> createToCalculatedFieldMsgConsumer() { + return new InMemoryTbQueueConsumer<>(storage, topicService.buildTopicName(calculatedFieldSettings.getEventTopic())); + } + + @Override + public TbQueueAdmin getCalculatedFieldQueueAdmin() { + return queueAdmin; + } + + @Override + public TbQueueProducer> createToCalculatedFieldMsgProducer() { + return new InMemoryTbQueueProducer<>(storage, topicService.buildTopicName(calculatedFieldSettings.getEventTopic())); + } + + @Override + public TbQueueConsumer> createToCalculatedFieldNotificationsMsgConsumer() { + return new InMemoryTbQueueConsumer<>(storage, topicService.getCalculatedFieldNotificationsTopic(serviceInfoProvider.getServiceId()).getFullTopicName()); + } + + @Override + public TbQueueConsumer> createCalculatedFieldStateConsumer() { + return new InMemoryTbQueueConsumer<>(storage, topicService.buildTopicName(calculatedFieldSettings.getStateTopic())); + } + + @Override + public TbQueueProducer> createCalculatedFieldStateProducer() { + return new InMemoryTbQueueProducer<>(storage, topicService.buildTopicName(calculatedFieldSettings.getStateTopic())); + } + @Override public TbQueueConsumer> createToUsageStatsServiceMsgConsumer() { return new InMemoryTbQueueConsumer<>(storage, topicService.buildTopicName(coreSettings.getUsageStatsTopic())); @@ -209,6 +233,31 @@ public class InMemoryMonolithQueueFactory implements TbCoreQueueFactory, TbRuleE return null; } + @Override + public TbQueueProducer> createToCalculatedFieldNotificationMsgProducer() { + return new InMemoryTbQueueProducer<>(storage, topicService.buildTopicName(calculatedFieldSettings.getEventTopic())); + } + + @Override + public TbQueueProducer> createEdqsMsgProducer(EdqsQueue queue) { + return new InMemoryTbQueueProducer<>(storage, queue.getTopic()); + } + + @Override + public TbQueueRequestTemplate, TbProtoQueueMsg> createEdqsRequestTemplate() { + TbQueueProducer> requestProducer = new InMemoryTbQueueProducer<>(storage, edqsConfig.getRequestsTopic()); + TbQueueConsumer> responseConsumer = new InMemoryTbQueueConsumer<>(storage, edqsConfig.getResponsesTopic()); + + return DefaultTbQueueRequestTemplate., TbProtoQueueMsg>builder() + .queueAdmin(queueAdmin) + .requestTemplate(requestProducer) + .responseTemplate(responseConsumer) + .maxPendingRequests(edqsConfig.getMaxPendingRequests()) + .maxRequestTimeout(edqsConfig.getMaxRequestTimeout()) + .pollInterval(edqsConfig.getPollInterval()) + .build(); + } + @Scheduled(fixedRateString = "${queue.in_memory.stats.print-interval-ms:60000}") private void printInMemoryStats() { storage.printStats(); diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaMonolithQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaMonolithQueueFactory.java index f1210c4d40..f07bd9dcbb 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaMonolithQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaMonolithQueueFactory.java @@ -25,11 +25,16 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.queue.Queue; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.gen.js.JsInvokeProtos; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; +import org.thingsboard.server.gen.transport.TransportProtos.FromEdqsMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeNotificationMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToHousekeeperServiceMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToOtaPackageStateServiceMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; @@ -48,12 +53,15 @@ import org.thingsboard.server.queue.common.TbProtoJsQueueMsg; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; import org.thingsboard.server.queue.discovery.TopicService; +import org.thingsboard.server.queue.edqs.EdqsConfig; +import org.thingsboard.server.queue.edqs.EdqsQueue; import org.thingsboard.server.queue.kafka.TbKafkaAdmin; import org.thingsboard.server.queue.kafka.TbKafkaConsumerStatsService; import org.thingsboard.server.queue.kafka.TbKafkaConsumerTemplate; import org.thingsboard.server.queue.kafka.TbKafkaProducerTemplate; import org.thingsboard.server.queue.kafka.TbKafkaSettings; import org.thingsboard.server.queue.kafka.TbKafkaTopicConfigs; +import org.thingsboard.server.queue.settings.TbQueueCalculatedFieldSettings; import org.thingsboard.server.queue.settings.TbQueueCoreSettings; import org.thingsboard.server.queue.settings.TbQueueEdgeSettings; import org.thingsboard.server.queue.settings.TbQueueRemoteJsInvokeSettings; @@ -79,7 +87,9 @@ public class KafkaMonolithQueueFactory implements TbCoreQueueFactory, TbRuleEngi private final TbQueueRemoteJsInvokeSettings jsInvokeSettings; private final TbQueueVersionControlSettings vcSettings; private final TbQueueEdgeSettings edgeSettings; + private final TbQueueCalculatedFieldSettings calculatedFieldSettings; private final TbKafkaConsumerStatsService consumerStatsService; + private final EdqsConfig edqsConfig; private final TbQueueAdmin coreAdmin; private final TbKafkaAdmin ruleEngineAdmin; @@ -94,6 +104,10 @@ public class KafkaMonolithQueueFactory implements TbCoreQueueFactory, TbRuleEngi private final TbQueueAdmin housekeeperReprocessingAdmin; private final TbQueueAdmin edgeAdmin; private final TbQueueAdmin edgeEventAdmin; + private final TbQueueAdmin cfAdmin; + private final TbQueueAdmin cfStateAdmin; + private final TbQueueAdmin edqsEventsAdmin; + private final TbKafkaAdmin edqsRequestsAdmin; private final AtomicLong consumerCount = new AtomicLong(); private final AtomicLong edgeConsumerCount = new AtomicLong(); @@ -107,8 +121,10 @@ public class KafkaMonolithQueueFactory implements TbCoreQueueFactory, TbRuleEngi TbQueueRemoteJsInvokeSettings jsInvokeSettings, TbQueueVersionControlSettings vcSettings, TbQueueEdgeSettings edgeSettings, + TbQueueCalculatedFieldSettings calculatedFieldSettings, TbKafkaConsumerStatsService consumerStatsService, - TbKafkaTopicConfigs kafkaTopicConfigs) { + TbKafkaTopicConfigs kafkaTopicConfigs, + EdqsConfig edqsConfig) { this.topicService = topicService; this.kafkaSettings = kafkaSettings; this.serviceInfoProvider = serviceInfoProvider; @@ -120,6 +136,8 @@ public class KafkaMonolithQueueFactory implements TbCoreQueueFactory, TbRuleEngi this.vcSettings = vcSettings; this.consumerStatsService = consumerStatsService; this.edgeSettings = edgeSettings; + this.calculatedFieldSettings = calculatedFieldSettings; + this.edqsConfig = edqsConfig; this.coreAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getCoreConfigs()); this.ruleEngineAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getRuleEngineConfigs()); @@ -134,6 +152,10 @@ public class KafkaMonolithQueueFactory implements TbCoreQueueFactory, TbRuleEngi this.housekeeperReprocessingAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getHousekeeperReprocessingConfigs()); this.edgeAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getEdgeConfigs()); this.edgeEventAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getEdgeEventConfigs()); + this.cfAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getCalculatedFieldConfigs()); + this.cfStateAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getCalculatedFieldStateConfigs()); + this.edqsEventsAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getEdqsEventsConfigs()); + this.edqsRequestsAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getEdqsRequestsConfigs()); } @Override @@ -472,7 +494,7 @@ public class KafkaMonolithQueueFactory implements TbCoreQueueFactory, TbRuleEngi public TbQueueConsumer> createEdgeEventMsgConsumer(TenantId tenantId, EdgeId edgeId) { TbKafkaConsumerTemplate.TbKafkaConsumerTemplateBuilder> consumerBuilder = TbKafkaConsumerTemplate.builder(); consumerBuilder.settings(kafkaSettings); - consumerBuilder.topic(topicService.buildTopicName("tb_edge_event.notifications." + tenantId + "." + edgeId)); + consumerBuilder.topic(topicService.buildEdgeEventNotificationsTopicPartitionInfo(tenantId, edgeId).getTopic()); consumerBuilder.clientId("monolith-to-edge-event-consumer-" + serviceInfoProvider.getServiceId() + "-" + edgeConsumerCount.incrementAndGet()); consumerBuilder.groupId(topicService.buildTopicName("monolith-edge-event-consumer")); consumerBuilder.decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), ToEdgeEventNotificationMsg.parseFrom(msg.getData()), msg.getHeaders())); @@ -491,6 +513,118 @@ public class KafkaMonolithQueueFactory implements TbCoreQueueFactory, TbRuleEngi return requestBuilder.build(); } + @Override + public TbQueueConsumer> createToCalculatedFieldMsgConsumer() { + TbKafkaConsumerTemplate.TbKafkaConsumerTemplateBuilder> consumerBuilder = TbKafkaConsumerTemplate.builder(); + consumerBuilder.settings(kafkaSettings); + consumerBuilder.topic(topicService.buildTopicName(calculatedFieldSettings.getEventTopic())); + consumerBuilder.clientId("monolith-calculated-field-consumer-" + serviceInfoProvider.getServiceId() + "-" + consumerCount.incrementAndGet()); + consumerBuilder.groupId(topicService.buildTopicName("monolith-calculated-field-consumer")); + consumerBuilder.decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), ToCalculatedFieldMsg.parseFrom(msg.getData()), msg.getHeaders())); + consumerBuilder.admin(cfAdmin); + consumerBuilder.statsService(consumerStatsService); + return consumerBuilder.build(); + } + + @Override + public TbQueueAdmin getCalculatedFieldQueueAdmin() { + return cfAdmin; + } + + @Override + public TbQueueProducer> createToCalculatedFieldMsgProducer() { + TbKafkaProducerTemplate.TbKafkaProducerTemplateBuilder> requestBuilder = TbKafkaProducerTemplate.builder(); + requestBuilder.settings(kafkaSettings); + requestBuilder.clientId("monolith-calculated-field-" + serviceInfoProvider.getServiceId()); + requestBuilder.defaultTopic(topicService.buildTopicName(calculatedFieldSettings.getEventTopic())); + requestBuilder.admin(cfAdmin); + return requestBuilder.build(); + } + + @Override + public TbQueueConsumer> createToCalculatedFieldNotificationsMsgConsumer() { + TbKafkaConsumerTemplate.TbKafkaConsumerTemplateBuilder> consumerBuilder = TbKafkaConsumerTemplate.builder(); + consumerBuilder.settings(kafkaSettings); + consumerBuilder.topic(topicService.getCalculatedFieldNotificationsTopic(serviceInfoProvider.getServiceId()).getFullTopicName()); + consumerBuilder.clientId("monolith-calculated-field-notifications-consumer-" + serviceInfoProvider.getServiceId()); + consumerBuilder.groupId(topicService.buildTopicName("monolith-calculated-field-notifications-consumer-" + serviceInfoProvider.getServiceId())); + consumerBuilder.decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), ToCalculatedFieldNotificationMsg.parseFrom(msg.getData()), msg.getHeaders())); + consumerBuilder.admin(notificationAdmin); + consumerBuilder.statsService(consumerStatsService); + return consumerBuilder.build(); + } + + @Override + public TbQueueProducer> createToCalculatedFieldNotificationMsgProducer() { + TbKafkaProducerTemplate.TbKafkaProducerTemplateBuilder> requestBuilder = TbKafkaProducerTemplate.builder(); + requestBuilder.settings(kafkaSettings); + requestBuilder.clientId("monolith-calculated-field-notifications-" + serviceInfoProvider.getServiceId()); + requestBuilder.defaultTopic(topicService.buildTopicName(calculatedFieldSettings.getEventTopic())); + requestBuilder.admin(notificationAdmin); + return requestBuilder.build(); + } + + @Override + public TbQueueConsumer> createCalculatedFieldStateConsumer() { + return TbKafkaConsumerTemplate.>builder() + .settings(kafkaSettings) + .topic(topicService.buildTopicName(calculatedFieldSettings.getStateTopic())) + .readFromBeginning(true) + .stopWhenRead(true) + .clientId("monolith-calculated-field-state-consumer-" + serviceInfoProvider.getServiceId() + "-" + consumerCount.incrementAndGet()) + .groupId(topicService.buildTopicName("monolith-calculated-field-state-consumer")) + .decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), msg.getData() != null ? CalculatedFieldStateProto.parseFrom(msg.getData()) : null, msg.getHeaders())) + .admin(cfStateAdmin) + .statsService(consumerStatsService) + .build(); + } + + @Override + public TbQueueProducer> createCalculatedFieldStateProducer() { + return TbKafkaProducerTemplate.>builder() + .settings(kafkaSettings) + .clientId("monolith-calculated-field-state-" + serviceInfoProvider.getServiceId()) + .defaultTopic(topicService.buildTopicName(calculatedFieldSettings.getEventTopic())) + .admin(cfStateAdmin) + .build(); + } + + @Override + public TbQueueProducer> createEdqsMsgProducer(EdqsQueue queue) { + return TbKafkaProducerTemplate.>builder() + .clientId("edqs-producer-" + queue.name().toLowerCase() + "-" + serviceInfoProvider.getServiceId()) + .settings(kafkaSettings) + .admin(edqsEventsAdmin) + .build(); + } + + @Override + public TbQueueRequestTemplate, TbProtoQueueMsg> createEdqsRequestTemplate() { + var requestProducer = TbKafkaProducerTemplate.>builder() + .settings(kafkaSettings) + .clientId("edqs-request-" + serviceInfoProvider.getServiceId()) + .defaultTopic(topicService.buildTopicName(edqsConfig.getRequestsTopic())) + .admin(edqsRequestsAdmin); + + var responseConsumer = TbKafkaConsumerTemplate.>builder() + .settings(kafkaSettings) + .topic(topicService.buildTopicName(edqsConfig.getResponsesTopic() + "." + serviceInfoProvider.getServiceId())) + .clientId("monolith-edqs-response-consumer-" + serviceInfoProvider.getServiceId()) + .groupId(topicService.buildTopicName("monolith-edqs-response-consumer-" + serviceInfoProvider.getServiceId())) + .decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), FromEdqsMsg.parseFrom(msg.getData()), msg.getHeaders())) + .admin(edqsRequestsAdmin) + .statsService(consumerStatsService); + + return DefaultTbQueueRequestTemplate., TbProtoQueueMsg>builder() + .queueAdmin(edqsRequestsAdmin) + .requestTemplate(requestProducer.build()) + .responseTemplate(responseConsumer.build()) + .maxPendingRequests(edqsConfig.getMaxPendingRequests()) + .maxRequestTimeout(edqsConfig.getMaxRequestTimeout()) + .pollInterval(edqsConfig.getPollInterval()) + .build(); + } + @PreDestroy private void destroy() { if (coreAdmin != null) { @@ -523,5 +657,9 @@ public class KafkaMonolithQueueFactory implements TbCoreQueueFactory, TbRuleEngi if (edgeAdmin != null) { edgeAdmin.destroy(); } + if (cfAdmin != null) { + cfAdmin.destroy(); + } } + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbCoreQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbCoreQueueFactory.java index c2f4649106..3c6d144a0c 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbCoreQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbCoreQueueFactory.java @@ -24,11 +24,15 @@ import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.gen.js.JsInvokeProtos; +import org.thingsboard.server.gen.transport.TransportProtos.FromEdqsMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeNotificationMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToHousekeeperServiceMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToOtaPackageStateServiceMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; @@ -47,12 +51,15 @@ import org.thingsboard.server.queue.common.TbProtoJsQueueMsg; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; import org.thingsboard.server.queue.discovery.TopicService; +import org.thingsboard.server.queue.edqs.EdqsConfig; +import org.thingsboard.server.queue.edqs.EdqsQueue; import org.thingsboard.server.queue.kafka.TbKafkaAdmin; import org.thingsboard.server.queue.kafka.TbKafkaConsumerStatsService; import org.thingsboard.server.queue.kafka.TbKafkaConsumerTemplate; import org.thingsboard.server.queue.kafka.TbKafkaProducerTemplate; import org.thingsboard.server.queue.kafka.TbKafkaSettings; import org.thingsboard.server.queue.kafka.TbKafkaTopicConfigs; +import org.thingsboard.server.queue.settings.TbQueueCalculatedFieldSettings; import org.thingsboard.server.queue.settings.TbQueueCoreSettings; import org.thingsboard.server.queue.settings.TbQueueEdgeSettings; import org.thingsboard.server.queue.settings.TbQueueRemoteJsInvokeSettings; @@ -79,6 +86,8 @@ public class KafkaTbCoreQueueFactory implements TbCoreQueueFactory { private final TbKafkaConsumerStatsService consumerStatsService; private final TbQueueTransportNotificationSettings transportNotificationSettings; private final TbQueueEdgeSettings edgeSettings; + private final TbQueueCalculatedFieldSettings calculatedFieldSettings; + private final EdqsConfig edqsConfig; private final TbQueueAdmin coreAdmin; private final TbQueueAdmin ruleEngineAdmin; @@ -93,6 +102,9 @@ public class KafkaTbCoreQueueFactory implements TbCoreQueueFactory { private final TbQueueAdmin housekeeperReprocessingAdmin; private final TbQueueAdmin edgeAdmin; private final TbQueueAdmin edgeEventAdmin; + private final TbQueueAdmin cfAdmin; + private final TbQueueAdmin edqsEventsAdmin; + private final TbKafkaAdmin edqsRequestsAdmin; private final AtomicLong consumerCount = new AtomicLong(); private final AtomicLong edgeConsumerCount = new AtomicLong(); @@ -108,6 +120,8 @@ public class KafkaTbCoreQueueFactory implements TbCoreQueueFactory { TbQueueEdgeSettings edgeSettings, TbKafkaConsumerStatsService consumerStatsService, TbQueueTransportNotificationSettings transportNotificationSettings, + TbQueueCalculatedFieldSettings calculatedFieldSettings, + EdqsConfig edqsConfig, TbKafkaTopicConfigs kafkaTopicConfigs) { this.topicService = topicService; this.kafkaSettings = kafkaSettings; @@ -120,6 +134,8 @@ public class KafkaTbCoreQueueFactory implements TbCoreQueueFactory { this.consumerStatsService = consumerStatsService; this.transportNotificationSettings = transportNotificationSettings; this.edgeSettings = edgeSettings; + this.calculatedFieldSettings = calculatedFieldSettings; + this.edqsConfig = edqsConfig; this.coreAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getCoreConfigs()); this.ruleEngineAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getRuleEngineConfigs()); @@ -134,6 +150,9 @@ public class KafkaTbCoreQueueFactory implements TbCoreQueueFactory { this.housekeeperReprocessingAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getHousekeeperReprocessingConfigs()); this.edgeAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getEdgeConfigs()); this.edgeEventAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getEdgeEventConfigs()); + this.cfAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getCalculatedFieldConfigs()); + this.edqsEventsAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getEdqsEventsConfigs()); + this.edqsRequestsAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getEdqsRequestsConfigs()); } @Override @@ -421,7 +440,7 @@ public class KafkaTbCoreQueueFactory implements TbCoreQueueFactory { public TbQueueConsumer> createEdgeEventMsgConsumer(TenantId tenantId, EdgeId edgeId) { TbKafkaConsumerTemplate.TbKafkaConsumerTemplateBuilder> consumerBuilder = TbKafkaConsumerTemplate.builder(); consumerBuilder.settings(kafkaSettings); - consumerBuilder.topic(topicService.buildTopicName("tb_edge_event.notifications." + tenantId + "." + edgeId)); + consumerBuilder.topic(topicService.buildEdgeEventNotificationsTopicPartitionInfo(tenantId, edgeId).getTopic()); consumerBuilder.clientId("tb-core-edge-event-consumer-" + serviceInfoProvider.getServiceId() + "-" + edgeConsumerCount.incrementAndGet()); consumerBuilder.groupId(topicService.buildTopicName("tb-core-edge-event-consumer")); consumerBuilder.decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), ToEdgeEventNotificationMsg.parseFrom(msg.getData()), msg.getHeaders())); @@ -440,6 +459,62 @@ public class KafkaTbCoreQueueFactory implements TbCoreQueueFactory { return requestBuilder.build(); } + @Override + public TbQueueProducer> createToCalculatedFieldMsgProducer() { + TbKafkaProducerTemplate.TbKafkaProducerTemplateBuilder> requestBuilder = TbKafkaProducerTemplate.builder(); + requestBuilder.settings(kafkaSettings); + requestBuilder.clientId("tb-core-to-calculated-field-" + serviceInfoProvider.getServiceId()); + requestBuilder.defaultTopic(topicService.buildTopicName(calculatedFieldSettings.getEventTopic())); + requestBuilder.admin(cfAdmin); + return requestBuilder.build(); + } + + @Override + public TbQueueProducer> createToCalculatedFieldNotificationMsgProducer() { + TbKafkaProducerTemplate.TbKafkaProducerTemplateBuilder> requestBuilder = TbKafkaProducerTemplate.builder(); + requestBuilder.settings(kafkaSettings); + requestBuilder.clientId("tb-core-calculated-field-notifications-" + serviceInfoProvider.getServiceId()); + requestBuilder.defaultTopic(topicService.buildTopicName(calculatedFieldSettings.getEventTopic())); + requestBuilder.admin(notificationAdmin); + return requestBuilder.build(); + } + + @Override + public TbQueueProducer> createEdqsMsgProducer(EdqsQueue queue) { + return TbKafkaProducerTemplate.>builder() + .clientId("edqs-producer-" + queue.name().toLowerCase() + "-" + serviceInfoProvider.getServiceId()) + .settings(kafkaSettings) + .admin(edqsEventsAdmin) + .build(); + } + + @Override + public TbQueueRequestTemplate, TbProtoQueueMsg> createEdqsRequestTemplate() { + var requestProducer = TbKafkaProducerTemplate.>builder() + .settings(kafkaSettings) + .clientId("edqs-request-" + serviceInfoProvider.getServiceId()) + .defaultTopic(topicService.buildTopicName(edqsConfig.getRequestsTopic())) + .admin(edqsRequestsAdmin); + + var responseConsumer = TbKafkaConsumerTemplate.>builder() + .settings(kafkaSettings) + .topic(topicService.buildTopicName(edqsConfig.getResponsesTopic() + "." + serviceInfoProvider.getServiceId())) + .clientId("tb-core-edqs-response-consumer-" + serviceInfoProvider.getServiceId()) + .groupId(topicService.buildTopicName("tb-core-edqs-response-consumer-" + serviceInfoProvider.getServiceId())) + .decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), FromEdqsMsg.parseFrom(msg.getData()), msg.getHeaders())) + .admin(edqsRequestsAdmin) + .statsService(consumerStatsService); + + return DefaultTbQueueRequestTemplate., TbProtoQueueMsg>builder() + .queueAdmin(edqsRequestsAdmin) + .requestTemplate(requestProducer.build()) + .responseTemplate(responseConsumer.build()) + .maxPendingRequests(edqsConfig.getMaxPendingRequests()) + .maxRequestTimeout(edqsConfig.getMaxRequestTimeout()) + .pollInterval(edqsConfig.getPollInterval()) + .build(); + } + @PreDestroy private void destroy() { if (coreAdmin != null) { @@ -469,5 +544,9 @@ public class KafkaTbCoreQueueFactory implements TbCoreQueueFactory { if (vcAdmin != null) { vcAdmin.destroy(); } + if (cfAdmin != null) { + cfAdmin.destroy(); + } } + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbRuleEngineQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbRuleEngineQueueFactory.java index 1fc01c8df6..d0e6c2f123 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbRuleEngineQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbRuleEngineQueueFactory.java @@ -23,11 +23,16 @@ import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.queue.Queue; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.gen.js.JsInvokeProtos; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; +import org.thingsboard.server.gen.transport.TransportProtos.FromEdqsMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeNotificationMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToEdqsMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToHousekeeperServiceMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToOtaPackageStateServiceMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; @@ -43,12 +48,14 @@ import org.thingsboard.server.queue.common.TbProtoJsQueueMsg; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; import org.thingsboard.server.queue.discovery.TopicService; +import org.thingsboard.server.queue.edqs.EdqsQueue; import org.thingsboard.server.queue.kafka.TbKafkaAdmin; import org.thingsboard.server.queue.kafka.TbKafkaConsumerStatsService; import org.thingsboard.server.queue.kafka.TbKafkaConsumerTemplate; import org.thingsboard.server.queue.kafka.TbKafkaProducerTemplate; import org.thingsboard.server.queue.kafka.TbKafkaSettings; import org.thingsboard.server.queue.kafka.TbKafkaTopicConfigs; +import org.thingsboard.server.queue.settings.TbQueueCalculatedFieldSettings; import org.thingsboard.server.queue.settings.TbQueueCoreSettings; import org.thingsboard.server.queue.settings.TbQueueEdgeSettings; import org.thingsboard.server.queue.settings.TbQueueRemoteJsInvokeSettings; @@ -71,6 +78,7 @@ public class KafkaTbRuleEngineQueueFactory implements TbRuleEngineQueueFactory { private final TbKafkaConsumerStatsService consumerStatsService; private final TbQueueTransportNotificationSettings transportNotificationSettings; private final TbQueueEdgeSettings edgeSettings; + private final TbQueueCalculatedFieldSettings calculatedFieldSettings; private final TbQueueAdmin coreAdmin; private final TbKafkaAdmin ruleEngineAdmin; @@ -81,6 +89,9 @@ public class KafkaTbRuleEngineQueueFactory implements TbRuleEngineQueueFactory { private final TbQueueAdmin housekeeperAdmin; private final TbQueueAdmin edgeAdmin; private final TbQueueAdmin edgeEventAdmin; + private final TbQueueAdmin cfAdmin; + private final TbQueueAdmin cfStateAdmin; + private final TbQueueAdmin edqsEventsAdmin; private final AtomicLong consumerCount = new AtomicLong(); public KafkaTbRuleEngineQueueFactory(TopicService topicService, TbKafkaSettings kafkaSettings, @@ -90,7 +101,7 @@ public class KafkaTbRuleEngineQueueFactory implements TbRuleEngineQueueFactory { TbQueueRemoteJsInvokeSettings jsInvokeSettings, TbKafkaConsumerStatsService consumerStatsService, TbQueueTransportNotificationSettings transportNotificationSettings, - TbQueueEdgeSettings edgeSettings, + TbQueueEdgeSettings edgeSettings, TbQueueCalculatedFieldSettings calculatedFieldSettings, TbKafkaTopicConfigs kafkaTopicConfigs) { this.topicService = topicService; this.kafkaSettings = kafkaSettings; @@ -101,6 +112,7 @@ public class KafkaTbRuleEngineQueueFactory implements TbRuleEngineQueueFactory { this.consumerStatsService = consumerStatsService; this.transportNotificationSettings = transportNotificationSettings; this.edgeSettings = edgeSettings; + this.calculatedFieldSettings = calculatedFieldSettings; this.coreAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getCoreConfigs()); this.ruleEngineAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getRuleEngineConfigs()); @@ -111,6 +123,9 @@ public class KafkaTbRuleEngineQueueFactory implements TbRuleEngineQueueFactory { this.housekeeperAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getHousekeeperConfigs()); this.edgeAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getEdgeConfigs()); this.edgeEventAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getEdgeEventConfigs()); + this.cfAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getCalculatedFieldConfigs()); + this.cfStateAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getCalculatedFieldStateConfigs()); + this.edqsEventsAdmin = new TbKafkaAdmin(kafkaSettings, kafkaTopicConfigs.getEdqsEventsConfigs()); } @Override @@ -293,6 +308,86 @@ public class KafkaTbRuleEngineQueueFactory implements TbRuleEngineQueueFactory { .build(); } + @Override + public TbQueueConsumer> createToCalculatedFieldMsgConsumer() { + TbKafkaConsumerTemplate.TbKafkaConsumerTemplateBuilder> consumerBuilder = TbKafkaConsumerTemplate.builder(); + consumerBuilder.settings(kafkaSettings); + consumerBuilder.topic(topicService.buildTopicName(calculatedFieldSettings.getEventTopic())); + consumerBuilder.clientId("tb-rule-engine-calculated-field-consumer-" + serviceInfoProvider.getServiceId() + "-" + consumerCount.incrementAndGet()); + consumerBuilder.groupId(topicService.buildTopicName("tb-rule-engine-calculated-field-consumer")); + consumerBuilder.decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), ToCalculatedFieldMsg.parseFrom(msg.getData()), msg.getHeaders())); + consumerBuilder.admin(cfAdmin); + consumerBuilder.statsService(consumerStatsService); + return consumerBuilder.build(); + } + + @Override + public TbQueueAdmin getCalculatedFieldQueueAdmin() { + return cfAdmin; + } + + @Override + public TbQueueProducer> createToCalculatedFieldMsgProducer() { + TbKafkaProducerTemplate.TbKafkaProducerTemplateBuilder> requestBuilder = TbKafkaProducerTemplate.builder(); + requestBuilder.settings(kafkaSettings); + requestBuilder.clientId("tb-rule-engine-to-calculated-field-" + serviceInfoProvider.getServiceId()); + requestBuilder.defaultTopic(topicService.buildTopicName(calculatedFieldSettings.getEventTopic())); + requestBuilder.admin(cfAdmin); + return requestBuilder.build(); + } + + @Override + public TbQueueConsumer> createToCalculatedFieldNotificationsMsgConsumer() { + TbKafkaConsumerTemplate.TbKafkaConsumerTemplateBuilder> consumerBuilder = TbKafkaConsumerTemplate.builder(); + consumerBuilder.settings(kafkaSettings); + consumerBuilder.topic(topicService.getCalculatedFieldNotificationsTopic(serviceInfoProvider.getServiceId()).getFullTopicName()); + consumerBuilder.clientId("tb-calculated-field-notifications-consumer-" + serviceInfoProvider.getServiceId()); + consumerBuilder.groupId(topicService.buildTopicName("tb-calculated-field-notifications-node-") + serviceInfoProvider.getServiceId()); + consumerBuilder.decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), ToCalculatedFieldNotificationMsg.parseFrom(msg.getData()), msg.getHeaders())); + consumerBuilder.admin(notificationAdmin); + consumerBuilder.statsService(consumerStatsService); + return consumerBuilder.build(); + } + + @Override + public TbQueueConsumer> createCalculatedFieldStateConsumer() { + return TbKafkaConsumerTemplate.>builder() + .settings(kafkaSettings) + .topic(topicService.buildTopicName(calculatedFieldSettings.getStateTopic())) + .readFromBeginning(true) + .stopWhenRead(true) + .clientId("tb-rule-engine-calculated-field-state-consumer-" + serviceInfoProvider.getServiceId() + "-" + consumerCount.incrementAndGet()) + .groupId(topicService.buildTopicName("tb-rule-engine-calculated-field-state-consumer")) + .decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), msg.getData() != null ? CalculatedFieldStateProto.parseFrom(msg.getData()) : null, msg.getHeaders())) + .admin(cfStateAdmin) + .statsService(consumerStatsService) + .build(); + } + + @Override + public TbQueueProducer> createCalculatedFieldStateProducer() { + return TbKafkaProducerTemplate.>builder() + .settings(kafkaSettings) + .clientId("tb-rule-engine-to-calculated-field-state-" + serviceInfoProvider.getServiceId()) + .defaultTopic(topicService.buildTopicName(calculatedFieldSettings.getEventTopic())) + .admin(cfStateAdmin) + .build(); + } + + @Override + public TbQueueProducer> createEdqsMsgProducer(EdqsQueue queue) { + return TbKafkaProducerTemplate.>builder() + .clientId("edqs-producer-" + queue.name().toLowerCase() + "-" + serviceInfoProvider.getServiceId()) + .settings(kafkaSettings) + .admin(edqsEventsAdmin) + .build(); + } + + @Override + public TbQueueRequestTemplate, TbProtoQueueMsg> createEdqsRequestTemplate() { + throw new UnsupportedOperationException(); + } + @PreDestroy private void destroy() { if (coreAdmin != null) { @@ -313,5 +408,8 @@ public class KafkaTbRuleEngineQueueFactory implements TbRuleEngineQueueFactory { if (fwUpdatesAdmin != null) { fwUpdatesAdmin.destroy(); } + if (cfAdmin != null) { + cfAdmin.destroy(); + } } } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbCoreQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbCoreQueueFactory.java index a89c901d73..037d1f2087 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbCoreQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbCoreQueueFactory.java @@ -18,6 +18,8 @@ package org.thingsboard.server.queue.provider; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.gen.js.JsInvokeProtos; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg; @@ -42,7 +44,7 @@ import org.thingsboard.server.queue.common.TbProtoQueueMsg; * Responsible for initialization of various Producers and Consumers used by TB Core Node. * Implementation Depends on the queue queue.type from yml or TB_QUEUE_TYPE environment variable */ -public interface TbCoreQueueFactory extends TbUsageStatsClientQueueFactory, HousekeeperClientQueueFactory { +public interface TbCoreQueueFactory extends TbUsageStatsClientQueueFactory, HousekeeperClientQueueFactory, EdqsClientQueueFactory { /** * Used to push messages to instances of TB Transport Service @@ -159,4 +161,8 @@ public interface TbCoreQueueFactory extends TbUsageStatsClientQueueFactory, Hous return null; } + TbQueueProducer> createToCalculatedFieldMsgProducer(); + + TbQueueProducer> createToCalculatedFieldNotificationMsgProducer(); + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbCoreQueueProducerProvider.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbCoreQueueProducerProvider.java index c65d12dfe6..7c3e415e9f 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbCoreQueueProducerProvider.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbCoreQueueProducerProvider.java @@ -17,6 +17,9 @@ package org.thingsboard.server.queue.provider; import jakarta.annotation.PostConstruct; import org.springframework.stereotype.Service; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg; @@ -48,6 +51,8 @@ public class TbCoreQueueProducerProvider implements TbQueueProducerProvider { private TbQueueProducer> toUsageStats; private TbQueueProducer> toVersionControl; private TbQueueProducer> toHousekeeper; + private TbQueueProducer> toCalculatedFields; + private TbQueueProducer> toCalculatedFieldNotifications; public TbCoreQueueProducerProvider(TbCoreQueueFactory tbQueueProvider) { this.tbQueueProvider = tbQueueProvider; @@ -66,6 +71,8 @@ public class TbCoreQueueProducerProvider implements TbQueueProducerProvider { this.toEdge = tbQueueProvider.createEdgeMsgProducer(); this.toEdgeNotifications = tbQueueProvider.createEdgeNotificationsMsgProducer(); this.toEdgeEvents = tbQueueProvider.createEdgeEventMsgProducer(); + this.toCalculatedFields = tbQueueProvider.createToCalculatedFieldMsgProducer(); + this.toCalculatedFieldNotifications = tbQueueProvider.createToCalculatedFieldNotificationMsgProducer(); } @Override @@ -124,4 +131,14 @@ public class TbCoreQueueProducerProvider implements TbQueueProducerProvider { return toEdgeEvents; } + @Override + public TbQueueProducer> getCalculatedFieldsMsgProducer() { + return toCalculatedFields; + } + + @Override + public TbQueueProducer> getCalculatedFieldsNotificationsMsgProducer() { + return toCalculatedFieldNotifications; + } + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbQueueProducerProvider.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbQueueProducerProvider.java index 5bfb4675cd..865637b2ff 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbQueueProducerProvider.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbQueueProducerProvider.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.queue.provider; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg; @@ -76,7 +78,7 @@ public interface TbQueueProducerProvider { */ TbQueueProducer> getTbUsageStatsMsgProducer(); - /** + /** * Used to push messages to other instances of TB Core Service * * @return @@ -91,4 +93,8 @@ public interface TbQueueProducerProvider { TbQueueProducer> getTbEdgeEventsMsgProducer(); + TbQueueProducer> getCalculatedFieldsMsgProducer(); + + TbQueueProducer> getCalculatedFieldsNotificationsMsgProducer(); + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbRuleEngineProducerProvider.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbRuleEngineProducerProvider.java index 8d1ec7f7f6..dcadf02d02 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbRuleEngineProducerProvider.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbRuleEngineProducerProvider.java @@ -18,6 +18,8 @@ package org.thingsboard.server.queue.provider; import jakarta.annotation.PostConstruct; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Service; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg; @@ -47,6 +49,7 @@ public class TbRuleEngineProducerProvider implements TbQueueProducerProvider { private TbQueueProducer> toEdge; private TbQueueProducer> toEdgeNotifications; private TbQueueProducer> toEdgeEvents; + private TbQueueProducer> toCalculatedFields; public TbRuleEngineProducerProvider(TbRuleEngineQueueFactory tbQueueProvider) { this.tbQueueProvider = tbQueueProvider; @@ -64,6 +67,7 @@ public class TbRuleEngineProducerProvider implements TbQueueProducerProvider { this.toEdge = tbQueueProvider.createEdgeMsgProducer(); this.toEdgeNotifications = tbQueueProvider.createEdgeNotificationsMsgProducer(); this.toEdgeEvents = tbQueueProvider.createEdgeEventMsgProducer(); + this.toCalculatedFields = tbQueueProvider.createToCalculatedFieldMsgProducer(); } @Override @@ -121,4 +125,14 @@ public class TbRuleEngineProducerProvider implements TbQueueProducerProvider { return toHousekeeper; } + @Override + public TbQueueProducer> getCalculatedFieldsMsgProducer() { + return toCalculatedFields; + } + + @Override + public TbQueueProducer> getCalculatedFieldsNotificationsMsgProducer() { + throw new RuntimeException("Not Implemented! Should not be used by Rule Engine Service!"); + } + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbRuleEngineQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbRuleEngineQueueFactory.java index 01523e0b87..03f662d8bb 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbRuleEngineQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbRuleEngineQueueFactory.java @@ -17,6 +17,9 @@ package org.thingsboard.server.queue.provider; import org.thingsboard.server.common.data.queue.Queue; import org.thingsboard.server.gen.js.JsInvokeProtos; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg; @@ -26,6 +29,7 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToOtaPackageStateSer import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToTransportMsg; +import org.thingsboard.server.queue.TbQueueAdmin; import org.thingsboard.server.queue.TbQueueConsumer; import org.thingsboard.server.queue.TbQueueProducer; import org.thingsboard.server.queue.TbQueueRequestTemplate; @@ -36,7 +40,7 @@ import org.thingsboard.server.queue.common.TbProtoQueueMsg; * Responsible for initialization of various Producers and Consumers used by TB Core Node. * Implementation Depends on the queue queue.type from yml or TB_QUEUE_TYPE environment variable */ -public interface TbRuleEngineQueueFactory extends TbUsageStatsClientQueueFactory, HousekeeperClientQueueFactory { +public interface TbRuleEngineQueueFactory extends TbUsageStatsClientQueueFactory, HousekeeperClientQueueFactory, EdqsClientQueueFactory { /** * Used to push messages to instances of TB Transport Service @@ -109,11 +113,24 @@ public interface TbRuleEngineQueueFactory extends TbUsageStatsClientQueueFactory } /** - * Used to consume high priority messages by TB Core Service + * Used to consume high priority messages by TB Rule Engine Service * * @return */ TbQueueConsumer> createToRuleEngineNotificationsMsgConsumer(); TbQueueRequestTemplate, TbProtoQueueMsg> createRemoteJsRequestTemplate(); + + TbQueueConsumer> createToCalculatedFieldMsgConsumer(); + + TbQueueAdmin getCalculatedFieldQueueAdmin(); + + TbQueueProducer> createToCalculatedFieldMsgProducer(); + + TbQueueConsumer> createToCalculatedFieldNotificationsMsgConsumer(); + + TbQueueConsumer> createCalculatedFieldStateConsumer(); + + TbQueueProducer> createCalculatedFieldStateProducer(); + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbTransportQueueProducerProvider.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbTransportQueueProducerProvider.java index e201ddc357..a7a34992cd 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbTransportQueueProducerProvider.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbTransportQueueProducerProvider.java @@ -18,6 +18,7 @@ package org.thingsboard.server.queue.provider; import jakarta.annotation.PostConstruct; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Service; +import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg; @@ -111,4 +112,13 @@ public class TbTransportQueueProducerProvider implements TbQueueProducerProvider return toHousekeeper; } + @Override + public TbQueueProducer> getCalculatedFieldsMsgProducer() { + throw new RuntimeException("Not Implemented! Should not be used by Transport!"); + } + + @Override + public TbQueueProducer> getCalculatedFieldsNotificationsMsgProducer() { + throw new RuntimeException("Not Implemented! Should not be used by Transport!"); + } } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbVersionControlProducerProvider.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbVersionControlProducerProvider.java index d5201e6518..85c400d094 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbVersionControlProducerProvider.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/TbVersionControlProducerProvider.java @@ -18,6 +18,7 @@ package org.thingsboard.server.queue.provider; import jakarta.annotation.PostConstruct; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Service; +import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg; @@ -107,4 +108,14 @@ public class TbVersionControlProducerProvider implements TbQueueProducerProvider return toHousekeeper; } + @Override + public TbQueueProducer> getCalculatedFieldsMsgProducer() { + throw new RuntimeException("Not Implemented! Should not be used by Version Control Service!"); + } + + @Override + public TbQueueProducer> getCalculatedFieldsNotificationsMsgProducer() { + throw new RuntimeException("Not Implemented! Should not be used by Version Control Service!"); + } + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/settings/TbQueueCalculatedFieldSettings.java b/common/queue/src/main/java/org/thingsboard/server/queue/settings/TbQueueCalculatedFieldSettings.java new file mode 100644 index 0000000000..c2de8eff4e --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/settings/TbQueueCalculatedFieldSettings.java @@ -0,0 +1,35 @@ +/** + * 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. + */ +package org.thingsboard.server.queue.settings; + +import lombok.Data; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; + +@Lazy +@Data +@Component +public class TbQueueCalculatedFieldSettings { + + @Value("${queue.calculated_fields.event_topic}") + private String eventTopic; + + @Value("${queue.calculated_fields.state_topic}") + private String stateTopic; + + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java b/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java index bc90a8c392..715020dc7c 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.queue.usagestats; +import com.google.common.collect.Lists; import jakarta.annotation.PostConstruct; import lombok.Data; import lombok.RequiredArgsConstructor; @@ -29,6 +30,9 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.common.stats.TbApiUsageReportClient; +import org.thingsboard.server.common.util.ProtoUtils; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.UsageStatsServiceMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToUsageStatsServiceMsg; import org.thingsboard.server.gen.transport.TransportProtos.UsageStatsKVProto; import org.thingsboard.server.queue.TbQueueProducer; @@ -38,7 +42,11 @@ import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.scheduler.SchedulerComponent; +import java.util.ArrayList; import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Random; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -57,6 +65,8 @@ public class DefaultTbApiUsageReportClient implements TbApiUsageReportClient { private boolean enabledPerCustomer; @Value("${usage.stats.report.interval:10}") private int interval; + @Value("${usage.stats.report.pack_size:1024}") + private int packSize; private final EnumMap> stats = new EnumMap<>(ApiUsageRecordKey.class); @@ -64,7 +74,7 @@ public class DefaultTbApiUsageReportClient implements TbApiUsageReportClient { private final TbServiceInfoProvider serviceInfoProvider; private final SchedulerComponent scheduler; private final TbQueueProducerProvider producerProvider; - private TbQueueProducer> msgProducer; + private TbQueueProducer> msgProducer; @PostConstruct private void init() { @@ -84,7 +94,7 @@ public class DefaultTbApiUsageReportClient implements TbApiUsageReportClient { } private void reportStats() { - ConcurrentMap report = new ConcurrentHashMap<>(); + ConcurrentMap report = new ConcurrentHashMap<>(); for (ApiUsageRecordKey key : ApiUsageRecordKey.values()) { ConcurrentMap statsForKey = stats.get(key); @@ -92,8 +102,8 @@ public class DefaultTbApiUsageReportClient implements TbApiUsageReportClient { long value = statsValue.get(); if (value == 0 && key.isCounter()) return; - ToUsageStatsServiceMsg.Builder statsMsg = report.computeIfAbsent(reportLevel.getParentEntity(), parent -> { - ToUsageStatsServiceMsg.Builder newStatsMsg = ToUsageStatsServiceMsg.newBuilder(); + UsageStatsServiceMsg.Builder statsMsg = report.computeIfAbsent(reportLevel.getParentEntity(), parent -> { + UsageStatsServiceMsg.Builder newStatsMsg = UsageStatsServiceMsg.newBuilder(); TenantId tenantId = parent.getTenantId(); newStatsMsg.setTenantIdMSB(tenantId.getId().getMostSignificantBits()); @@ -105,36 +115,55 @@ public class DefaultTbApiUsageReportClient implements TbApiUsageReportClient { newStatsMsg.setCustomerIdLSB(customerId.getId().getLeastSignificantBits()); } - newStatsMsg.setServiceId(serviceInfoProvider.getServiceId()); return newStatsMsg; }); UsageStatsKVProto.Builder statsItem = UsageStatsKVProto.newBuilder() - .setKey(key.name()) + .setRecordKey(ProtoUtils.toProto(key)) .setValue(value); statsMsg.addValues(statsItem.build()); }); statsForKey.clear(); } - report.forEach(((parent, statsMsg) -> { - //TODO: figure out how to minimize messages into the queue. Maybe group by 100s of messages? + Map> reportStatsPerTpi = new HashMap<>(); + + report.forEach((parent, statsMsg) -> { try { TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, parent.getTenantId(), parent.getId()) .newByTopic(msgProducer.getDefaultTopic()); - msgProducer.send(tpi, new TbProtoQueueMsg<>(UUID.randomUUID(), statsMsg.build()), null); + reportStatsPerTpi.computeIfAbsent(tpi, k -> new ArrayList<>()).add(statsMsg.build()); } catch (TenantNotFoundException e) { log.debug("Couldn't report usage stats for non-existing tenant: {}", e.getTenantId()); - } catch (Exception e) { - log.warn("Failed to report usage stats for tenant {}", parent.getTenantId(), e); } - })); + }); + + reportStatsPerTpi.forEach((tpi, statsList) -> { + toMsgPack(statsList).forEach(pack -> { + try { + msgProducer.send(tpi, new TbProtoQueueMsg<>(UUID.randomUUID(), pack), null); + } catch (Exception e) { + log.warn("Failed to report usage stats pack to TPI {}", tpi, e); + } + }); + }); if (!report.isEmpty()) { log.debug("Reporting API usage statistics for {} tenants and customers", report.size()); } } + private List toMsgPack(List list) { + return Lists.partition(list, packSize) + .stream() + .map(partition -> + ToUsageStatsServiceMsg.newBuilder() + .addAllMsgs(partition) + .setServiceId(serviceInfoProvider.getServiceId()) + .build()) + .toList(); + } + @Override public void report(TenantId tenantId, CustomerId customerId, ApiUsageRecordKey key, long value) { if (!enabled) return; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/util/AfterStartUp.java b/common/queue/src/main/java/org/thingsboard/server/queue/util/AfterStartUp.java index 8bd58c4e81..46c29b867b 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/util/AfterStartUp.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/util/AfterStartUp.java @@ -38,6 +38,9 @@ public @interface AfterStartUp { int ACTOR_SYSTEM = 9; int REGULAR_SERVICE = 10; + int CF_READ_PROFILE_ENTITIES_SERVICE = 10; + int CF_READ_CF_SERVICE = 11; + int BEFORE_TRANSPORT_SERVICE = Integer.MAX_VALUE - 1001; int TRANSPORT_SERVICE = Integer.MAX_VALUE - 1000; int AFTER_TRANSPORT_SERVICE = Integer.MAX_VALUE - 999; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/util/PropertyUtils.java b/common/queue/src/main/java/org/thingsboard/server/queue/util/PropertyUtils.java index ca34d4006c..6030eb278d 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/util/PropertyUtils.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/util/PropertyUtils.java @@ -43,9 +43,8 @@ public class PropertyUtils { } public static Map getProps(Map defaultProperties, String propertiesStr, Function> parser) { - Map properties = defaultProperties; + Map properties = new HashMap<>(defaultProperties); if (StringUtils.isNotBlank(propertiesStr)) { - properties = new HashMap<>(properties); properties.putAll(parser.apply(propertiesStr)); } return properties; diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/common/DefaultTbQueueRequestTemplateTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/common/DefaultTbQueueRequestTemplateTest.java index 1760555e07..9e493220ca 100644 --- a/common/queue/src/test/java/org/thingsboard/server/queue/common/DefaultTbQueueRequestTemplateTest.java +++ b/common/queue/src/test/java/org/thingsboard/server/queue/common/DefaultTbQueueRequestTemplateTest.java @@ -37,23 +37,11 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.greaterThanOrEqualTo; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.lessThan; +import static org.hamcrest.Matchers.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.BDDMockito.willAnswer; -import static org.mockito.BDDMockito.willDoNothing; -import static org.mockito.BDDMockito.willReturn; -import static org.mockito.Mockito.RETURNS_DEEP_STUBS; -import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; import static org.mockito.hamcrest.MockitoHamcrest.longThat; @Slf4j @@ -145,19 +133,19 @@ public class DefaultTbQueueRequestTemplateTest { @Test public void givenMessages_whenSend_thenOK() { - willDoNothing().given(inst).sendToRequestTemplate(any(), any(), any(), any()); + willDoNothing().given(inst).sendToRequestTemplate(any(), any(), any(), any(), any()); inst.init(); final int msgCount = 10; for (int i = 0; i < msgCount; i++) { inst.send(getRequestMsgMock()); } assertThat(inst.pendingRequests.mappingCount(), equalTo((long) msgCount)); - verify(inst, times(msgCount)).sendToRequestTemplate(any(), any(), any(), any()); + verify(inst, times(msgCount)).sendToRequestTemplate(any(), any(), any(), any(), any()); } @Test public void givenMessagesOverMaxPendingRequests_whenSend_thenImmediateFailedFutureForTheOfRequests() { - willDoNothing().given(inst).sendToRequestTemplate(any(), any(), any(), any()); + willDoNothing().given(inst).sendToRequestTemplate(any(), any(), any(), any(), any()); inst.init(); int msgOverflowCount = 10; for (int i = 0; i < inst.maxPendingRequests; i++) { @@ -167,7 +155,7 @@ public class DefaultTbQueueRequestTemplateTest { assertThat("max pending requests overflow", inst.send(getRequestMsgMock()).isDone(), is(true)); //overflow, immediate failed future } assertThat(inst.pendingRequests.mappingCount(), equalTo(inst.maxPendingRequests)); - verify(inst, times((int) inst.maxPendingRequests)).sendToRequestTemplate(any(), any(), any(), any()); + verify(inst, times((int) inst.maxPendingRequests)).sendToRequestTemplate(any(), any(), any(), any(), any()); } @SuppressWarnings("unchecked") diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java index efe6fd7781..3d9b855064 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java @@ -20,10 +20,16 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.script.api.tbel.TbelCfObject; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.stats.StatsCounter; +import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.common.stats.StatsType; import java.util.Map; import java.util.UUID; @@ -32,22 +38,31 @@ import java.util.concurrent.Executor; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicInteger; import static java.lang.String.format; @Slf4j public abstract class AbstractScriptInvokeService implements ScriptInvokeService { + private static final String REQUESTS = "requests"; + private static final String INVOKE_RESPONSES = "invoke_responses"; + private static final String EVAL_RESPONSES = "eval_responses"; + private static final String FAILURES = "failures"; + private static final String TIMEOUTS = "timeouts"; + protected final Map disabledScripts = new ConcurrentHashMap<>(); - private final AtomicInteger pushedMsgs = new AtomicInteger(0); - private final AtomicInteger invokeMsgs = new AtomicInteger(0); - private final AtomicInteger evalMsgs = new AtomicInteger(0); - protected final AtomicInteger failedMsgs = new AtomicInteger(0); - protected final AtomicInteger timeoutMsgs = new AtomicInteger(0); - private final FutureCallback evalCallback = new ScriptStatCallback<>(evalMsgs, timeoutMsgs, failedMsgs); - private final FutureCallback invokeCallback = new ScriptStatCallback<>(invokeMsgs, timeoutMsgs, failedMsgs); + private StatsCounter requestsCounter; + private StatsCounter invokeResponsesCounter; + private StatsCounter evalResponsesCounter; + private StatsCounter failuresCounter; + private StatsCounter timeoutsCounter; + + private FutureCallback evalCallback; + private FutureCallback invokeCallback; + + @Autowired + private StatsFactory statsFactory; protected ScheduledExecutorService timeoutExecutorService; @@ -76,6 +91,7 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService protected abstract boolean isScriptPresent(UUID scriptId); protected abstract boolean isExecEnabled(TenantId tenantId); + protected abstract void reportExecution(TenantId tenantId, CustomerId customerId); protected abstract ListenableFuture doEvalScript(TenantId tenantId, ScriptType scriptType, String scriptBody, UUID scriptId, String[] argNames); @@ -85,6 +101,14 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService protected abstract void doRelease(UUID scriptId) throws Exception; public void init() { + String key = getStatsType().getName(); + this.requestsCounter = statsFactory.createStatsCounter(key, REQUESTS); + this.invokeResponsesCounter = statsFactory.createStatsCounter(key, INVOKE_RESPONSES); + this.evalResponsesCounter = statsFactory.createStatsCounter(key, EVAL_RESPONSES); + this.failuresCounter = statsFactory.createStatsCounter(key, FAILURES); + this.timeoutsCounter = statsFactory.createStatsCounter(key, TIMEOUTS); + this.evalCallback = new ScriptStatCallback<>(evalResponsesCounter, timeoutsCounter, failuresCounter); + this.invokeCallback = new ScriptStatCallback<>(invokeResponsesCounter, timeoutsCounter, failuresCounter); if (getMaxEvalRequestsTimeout() > 0 || getMaxInvokeRequestsTimeout() > 0) { timeoutExecutorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("script-timeout"); } @@ -98,11 +122,11 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService public void printStats() { if (isStatsEnabled()) { - int pushed = pushedMsgs.getAndSet(0); - int invoked = invokeMsgs.getAndSet(0); - int evaluated = evalMsgs.getAndSet(0); - int failed = failedMsgs.getAndSet(0); - int timedOut = timeoutMsgs.getAndSet(0); + int pushed = requestsCounter.getAndClear(); + int invoked = invokeResponsesCounter.getAndClear(); + int evaluated = evalResponsesCounter.getAndClear(); + int failed = failuresCounter.getAndClear(); + int timedOut = timeoutsCounter.getAndClear(); if (pushed > 0 || invoked > 0 || evaluated > 0 || failed > 0 || timedOut > 0) { log.info("{}: pushed [{}] received [{}] invoke [{}] eval [{}] failed [{}] timedOut [{}]", getStatsName(), pushed, invoked + evaluated, invoked, evaluated, failed, timedOut); @@ -117,7 +141,7 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService return error(format("Script body exceeds maximum allowed size of %s symbols", getMaxScriptBodySize())); } UUID scriptId = UUID.randomUUID(); - pushedMsgs.incrementAndGet(); + requestsCounter.increment(); return withTimeoutAndStatsCallback(scriptId, null, doEvalScript(tenantId, scriptType, scriptBody, scriptId, argNames), evalCallback, getMaxEvalRequestsTimeout()); } else { @@ -139,7 +163,7 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService return Futures.immediateFailedFuture(handleScriptException(scriptId, null, t)); } reportExecution(tenantId, customerId); - pushedMsgs.incrementAndGet(); + requestsCounter.increment(); log.trace("[{}] InvokeScript uuid {} with timeout {}ms", tenantId, scriptId, getMaxInvokeRequestsTimeout()); var task = doInvokeFunction(scriptId, args); @@ -256,6 +280,8 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService for (Object arg : args) { if (arg instanceof CharSequence) { totalArgsSize += ((CharSequence) arg).length(); + } else if (arg instanceof TbelCfObject tbelCfObj) { + totalArgsSize += tbelCfObj.memorySize(); } else { var str = JacksonUtil.toString(arg); if (str != null) { @@ -274,4 +300,6 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService private ListenableFuture error(String message) { return Futures.immediateFailedFuture(new RuntimeException(message)); } + + protected abstract StatsType getStatsType(); } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptStatCallback.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptStatCallback.java index 33812b7852..fd6f5bf7d7 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptStatCallback.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptStatCallback.java @@ -19,29 +19,29 @@ import com.google.common.util.concurrent.FutureCallback; import jakarta.annotation.Nullable; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.stats.StatsCounter; import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicInteger; @Slf4j @AllArgsConstructor public class ScriptStatCallback implements FutureCallback { - private final AtomicInteger successMsgs; - private final AtomicInteger timeoutMsgs; - private final AtomicInteger failedMsgs; + private final StatsCounter successMsgs; + private final StatsCounter timeoutMsgs; + private final StatsCounter failedMsgs; @Override public void onSuccess(@Nullable T result) { - successMsgs.incrementAndGet(); + successMsgs.increment(); } @Override public void onFailure(Throwable t) { if (t instanceof TimeoutException || (t.getCause() != null && t.getCause() instanceof TimeoutException)) { - timeoutMsgs.incrementAndGet(); + timeoutMsgs.increment(); } else { - failedMsgs.incrementAndGet(); + failedMsgs.increment(); } } } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptType.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptType.java index 1eeed4a5a9..c1d8ae929a 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptType.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptType.java @@ -16,5 +16,5 @@ package org.thingsboard.script.api; public enum ScriptType { - RULE_NODE_SCRIPT + RULE_NODE_SCRIPT, CALCULATED_FIELD_SCRIPT } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java index 4049040c94..b6ddf16e66 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java @@ -26,6 +26,7 @@ import org.thingsboard.script.api.ScriptType; import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.stats.StatsType; import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.common.stats.TbApiUsageStateClient; @@ -117,4 +118,8 @@ public abstract class AbstractJsInvokeService extends AbstractScriptInvokeServic .hash().toString(); } + @Override + protected StatsType getStatsType() { + return StatsType.JS_INVOKE; + } } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java index c49e81052c..25a7ede547 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java @@ -44,6 +44,7 @@ import org.thingsboard.script.api.TbScriptException; import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.stats.StatsType; import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.common.stats.TbApiUsageStateClient; @@ -130,9 +131,17 @@ public class DefaultTbelInvokeService extends AbstractScriptInvokeService implem OptimizerFactory.setDefaultOptimizer(OptimizerFactory.SAFE_REFLECTIVE); parserConfig = ParserContext.enableSandboxedMode(); parserConfig.addImport("JSON", TbJson.class); - parserConfig.registerDataType("Date", TbDate.class, date -> 8L); - parserConfig.registerDataType("Random", Random.class, date -> 8L); - parserConfig.registerDataType("Calendar", Calendar.class, date -> 8L); + parserConfig.registerDataType("Date", TbDate.class, val -> 8L); + parserConfig.registerDataType("Random", Random.class, val -> 8L); + parserConfig.registerDataType("Calendar", Calendar.class, val -> 8L); + parserConfig.registerDataType("TbelCfSingleValueArg", TbelCfSingleValueArg.class, TbelCfSingleValueArg::memorySize); + parserConfig.registerDataType("TbelCfTsRollingArg", TbelCfTsRollingArg.class, TbelCfTsRollingArg::memorySize); + parserConfig.registerDataType("TbelCfTsDoubleVal", TbelCfTsDoubleVal.class, TbelCfTsDoubleVal::memorySize); + parserConfig.registerDataType("TbelCfTsRollingData", TbelCfTsRollingData.class, TbelCfTsRollingData::memorySize); + parserConfig.registerDataType("TbTimeWindow", TbTimeWindow.class, TbTimeWindow::memorySize); + parserConfig.registerDataType("TbelCfTsDoubleVal", TbelCfTsMultiDoubleVal.class, TbelCfTsMultiDoubleVal::memorySize); + parserConfig.registerDataType("TbelCfCtx", TbelCfCtx.class, TbelCfCtx::memorySize); + TbUtils.register(parserConfig); executor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(threadPoolSize, "tbel-executor")); try { @@ -255,4 +264,9 @@ public class DefaultTbelInvokeService extends AbstractScriptInvokeService implem protected long getMaxEvalRequestsTimeout() { return maxInvokeRequestsTimeout * 2; } + + @Override + protected StatsType getStatsType() { + return StatsType.TBEL_INVOKE; + } } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbTimeWindow.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbTimeWindow.java new file mode 100644 index 0000000000..1761ef6d0a --- /dev/null +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbTimeWindow.java @@ -0,0 +1,40 @@ +/** + * 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. + */ +package org.thingsboard.script.api.tbel; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class TbTimeWindow implements TbelCfObject { + + public static final long OBJ_SIZE = 32L; + + private long startTs; + private long endTs; + + @Override + public long memorySize() { + return OBJ_SIZE; + } + + public boolean matches(long ts) { + return ts >= startTs && ts < endTs; + } +} diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java index 9a57240d23..95fff48883 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java @@ -255,6 +255,10 @@ public class TbUtils { double.class, int.class))); parserConfig.addImport("toFixed", new MethodStub(TbUtils.class.getMethod("toFixed", float.class, int.class))); + parserConfig.addImport("toInt", new MethodStub(TbUtils.class.getMethod("toInt", + double.class))); + parserConfig.addImport("isNaN", new MethodStub(TbUtils.class.getMethod("isNaN", + double.class))); parserConfig.addImport("hexToBytes", new MethodStub(TbUtils.class.getMethod("hexToBytes", ExecutionContext.class, String.class))); parserConfig.addImport("hexToBytesArray", new MethodStub(TbUtils.class.getMethod("hexToBytesArray", @@ -1155,6 +1159,14 @@ public class TbUtils { return BigDecimal.valueOf(value).setScale(precision, RoundingMode.HALF_UP).floatValue(); } + public static int toInt(double value) { + return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).intValue(); + } + + public static boolean isNaN(double value) { + return Double.isNaN(value); + } + public static ExecutionHashMap toFlatMap(ExecutionContext ctx, Map json) { return toFlatMap(ctx, json, new ArrayList<>(), true); } @@ -1299,7 +1311,7 @@ public class TbUtils { if (str == null || str.isEmpty()) { return -1; } - return str.matches("[+-]?\\d+(\\.\\d+)?") ? DEC_RADIX : -1; + return str.matches("[+-]?\\d+(\\.\\d+)?([eE][+-]?\\d+)?") ? DEC_RADIX : -1; } public static int isHexadecimal(String str) { @@ -1506,5 +1518,6 @@ public class TbUtils { } return hex; } + } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java new file mode 100644 index 0000000000..f95b08195e --- /dev/null +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java @@ -0,0 +1,36 @@ +/** + * 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. + */ +package org.thingsboard.script.api.tbel; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = TbelCfSingleValueArg.class, name = "SINGLE_VALUE"), + @JsonSubTypes.Type(value = TbelCfTsRollingArg.class, name = "TS_ROLLING") +}) +public interface TbelCfArg extends TbelCfObject { + + @JsonIgnore + String getType(); + +} diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java new file mode 100644 index 0000000000..ce42e2cf3b --- /dev/null +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java @@ -0,0 +1,36 @@ +/** + * 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. + */ +package org.thingsboard.script.api.tbel; + +import lombok.Getter; + +import java.util.Collections; +import java.util.Map; + +public class TbelCfCtx implements TbelCfObject { + + @Getter + private final Map args; + + public TbelCfCtx(Map args) { + this.args = Collections.unmodifiableMap(args); + } + + @Override + public long memorySize() { + return OBJ_SIZE; + } +} diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfObject.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfObject.java new file mode 100644 index 0000000000..cf575bd9d0 --- /dev/null +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfObject.java @@ -0,0 +1,24 @@ +/** + * 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. + */ +package org.thingsboard.script.api.tbel; + +public interface TbelCfObject { + + long OBJ_SIZE = 32L; // Approximate calculation; + + long memorySize(); + +} diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfSingleValueArg.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfSingleValueArg.java new file mode 100644 index 0000000000..84227a8d80 --- /dev/null +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfSingleValueArg.java @@ -0,0 +1,51 @@ +/** + * 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. + */ +package org.thingsboard.script.api.tbel; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class TbelCfSingleValueArg implements TbelCfArg { + + private final long ts; + private final Object value; + + @JsonCreator + public TbelCfSingleValueArg( + @JsonProperty("ts") long ts, + @JsonProperty("value") Object value + ) { + this.ts = ts; + this.value = value; + } + + @Override + public long memorySize() { + if (value instanceof String strValue) { + return OBJ_SIZE + strValue.length(); + } else { + return OBJ_SIZE; + } + } + + @Override + public String getType() { + return "SINGLE_VALUE"; + } + +} diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsDoubleVal.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsDoubleVal.java new file mode 100644 index 0000000000..71565f3e1d --- /dev/null +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsDoubleVal.java @@ -0,0 +1,32 @@ +/** + * 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. + */ +package org.thingsboard.script.api.tbel; + +import lombok.Data; + +@Data +public class TbelCfTsDoubleVal implements TbelCfObject { + + public static final long OBJ_SIZE = 32L; // Approximate calculation; + + private final long ts; + private final double value; + + @Override + public long memorySize() { + return OBJ_SIZE; + } +} diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsMultiDoubleVal.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsMultiDoubleVal.java new file mode 100644 index 0000000000..2743bbd0de --- /dev/null +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsMultiDoubleVal.java @@ -0,0 +1,66 @@ +/** + * 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. + */ +package org.thingsboard.script.api.tbel; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; + +@Data +public class TbelCfTsMultiDoubleVal implements TbelCfObject { + + public static final long OBJ_SIZE = 32L; // Approximate calculation; + + private final long ts; + private final double[] values; + + @JsonIgnore + public double getV1() { + return getV(0); + } + + @JsonIgnore + public double getV2() { + return getV(1); + } + + @JsonIgnore + public double getV3() { + return getV(2); + } + + @JsonIgnore + public double getV4() { + return getV(3); + } + + @JsonIgnore + public double getV5() { + return getV(4); + } + + private double getV(int idx) { + if (values.length < idx + 1) { + throw new IllegalArgumentException("Can't get value at index " + idx + ". There are " + values.length + " values present."); + } else { + return values[idx]; + } + } + + @Override + public long memorySize() { + return OBJ_SIZE + values.length * 8L; + } +} diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java new file mode 100644 index 0000000000..3b5a7ac9bd --- /dev/null +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java @@ -0,0 +1,368 @@ +/** + * 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. + */ +package org.thingsboard.script.api.tbel; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import org.thingsboard.common.util.JacksonUtil; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; +import java.util.function.Consumer; + +import static org.thingsboard.script.api.tbel.TbelCfTsDoubleVal.OBJ_SIZE; + +public class TbelCfTsRollingArg implements TbelCfArg, Iterable { + + @Getter + private final TbTimeWindow timeWindow; + @Getter + private final List values; + + @JsonCreator + public TbelCfTsRollingArg( + @JsonProperty("timeWindow") TbTimeWindow timeWindow, + @JsonProperty("values") List values + ) { + this.timeWindow = timeWindow; + this.values = Collections.unmodifiableList(values); + } + + public TbelCfTsRollingArg(long timeWindow, List values) { + long ts = System.currentTimeMillis(); + this.timeWindow = new TbTimeWindow(ts - timeWindow, ts); + this.values = Collections.unmodifiableList(values); + } + + @Override + public long memorySize() { + return 12 + values.size() * OBJ_SIZE; + } + + @JsonIgnore + public List getValue() { + return values; + } + + public double max() { + return max(true); + } + + public double max(boolean ignoreNaN) { + if (values.isEmpty()) { + throw new IllegalArgumentException("Rolling argument values are empty."); + } + + double max = Double.MIN_VALUE; + for (TbelCfTsDoubleVal value : values) { + double val = value.getValue(); + if (!ignoreNaN && Double.isNaN(val)) { + return val; + } + if (max < val) { + max = val; + } + } + return max; + } + + public double min() { + return min(true); + } + + public double min(boolean ignoreNaN) { + if (values.isEmpty()) { + throw new IllegalArgumentException("Rolling argument values are empty."); + } + + double min = Double.MAX_VALUE; + for (TbelCfTsDoubleVal value : values) { + double val = value.getValue(); + if (!ignoreNaN && Double.isNaN(val)) { + return Double.NaN; + } + if (min > val) { + min = val; + } + } + return min; + } + + public double avg() { + return avg(true); + } + + public double avg(boolean ignoreNaN) { + return mean(ignoreNaN); + } + + public double mean() { + return mean(true); + } + + public double mean(boolean ignoreNaN) { + if (values.isEmpty()) { + throw new IllegalArgumentException("Rolling argument values are empty."); + } + + return sum(ignoreNaN) / count(ignoreNaN); + } + + public double std() { + return std(true); + } + + public double std(boolean ignoreNaN) { + if (values.isEmpty()) { + throw new IllegalArgumentException("Rolling argument values are empty."); + } + + double mean = mean(ignoreNaN); + if (!ignoreNaN && Double.isNaN(mean)) { + return Double.NaN; + } + + double sum = 0; + for (TbelCfTsDoubleVal value : values) { + double val = value.getValue(); + if (Double.isNaN(val)) { + if (!ignoreNaN) { + return Double.NaN; + } + } else { + sum += Math.pow(val - mean, 2); + } + } + return Math.sqrt(sum / count(ignoreNaN)); + } + + public double median() { + return median(true); + } + + public double median(boolean ignoreNaN) { + if (values.isEmpty()) { + throw new IllegalArgumentException("Rolling argument values are empty."); + } + + List sortedValues = new ArrayList<>(); + for (TbelCfTsDoubleVal value : values) { + double val = value.getValue(); + if (Double.isNaN(val)) { + if (!ignoreNaN) { + return Double.NaN; + } + } else { + sortedValues.add(val); + } + } + Collections.sort(sortedValues); + + int size = sortedValues.size(); + return (size % 2 == 1) + ? sortedValues.get(size / 2) + : (sortedValues.get(size / 2 - 1) + sortedValues.get(size / 2)) / 2.0; + } + + public int count() { + return count(true); + } + + public int count(boolean ignoreNaN) { + int count = 0; + if (ignoreNaN) { + for (TbelCfTsDoubleVal value : values) { + if (!Double.isNaN(value.getValue())) { + count++; + } + } + return count; + } + return values.size(); + } + + public double last() { + return last(true); + } + + public double last(boolean ignoreNaN) { + if (values.isEmpty()) { + throw new IllegalArgumentException("Rolling argument values are empty."); + } + + double value = values.get(values.size() - 1).getValue(); + if (!Double.isNaN(value) || !ignoreNaN) { + return value; + } + for (int i = values.size() - 2; i >= 0; i--) { + double prevValue = values.get(i).getValue(); + if (!Double.isNaN(prevValue)) { + return prevValue; + } + } + throw new IllegalArgumentException("Rolling argument values are empty."); + } + + public double first() { + return first(true); + } + + public double first(boolean ignoreNaN) { + if (values.isEmpty()) { + throw new IllegalArgumentException("Rolling argument values are empty."); + } + + double firstValue = values.get(0).getValue(); + if (!Double.isNaN(firstValue) || !ignoreNaN) { + return firstValue; + } + for (int i = 1; i < values.size(); i++) { + double nextValue = values.get(i).getValue(); + if (!Double.isNaN(nextValue)) { + return nextValue; + } + } + throw new IllegalArgumentException("Rolling argument values are empty."); + } + + public double sum() { + return sum(true); + } + + public double sum(boolean ignoreNaN) { + if (values.isEmpty()) { + throw new IllegalArgumentException("Rolling argument values are empty."); + } + + double sum = 0; + for (TbelCfTsDoubleVal value : values) { + double val = value.getValue(); + if (Double.isNaN(val)) { + if (!ignoreNaN) { + return Double.NaN; + } + } else { + sum += val; + } + } + return sum; + } + + public TbelCfTsRollingData merge(TbelCfTsRollingArg other) { + return mergeAll(Collections.singletonList(other), null); + } + + public TbelCfTsRollingData merge(TbelCfTsRollingArg other, Map settings) { + return mergeAll(Collections.singletonList(other), settings); + } + + public TbelCfTsRollingData mergeAll(List others) { + return mergeAll(others, null); + } + + public TbelCfTsRollingData mergeAll(List others, Map settings) { + List args = new ArrayList<>(others.size() + 1); + args.add(this); + args.addAll(others); + + boolean ignoreNaN = true; + if (settings != null && settings.containsKey("ignoreNaN")) { + ignoreNaN = Boolean.parseBoolean(settings.get("ignoreNaN").toString()); + } + + TbTimeWindow timeWindow = null; + if (settings != null && settings.containsKey("timeWindow")) { + var twVar = settings.get("timeWindow"); + if (twVar instanceof TbTimeWindow) { + timeWindow = (TbTimeWindow) settings.get("timeWindow"); + } else if (twVar instanceof Map twMap) { + timeWindow = new TbTimeWindow(Long.valueOf(twMap.get("startTs").toString()), Long.valueOf(twMap.get("endTs").toString())); + } else { + timeWindow = JacksonUtil.fromString(settings.get("timeWindow").toString(), TbTimeWindow.class); + } + } + + TreeSet allTimestamps = new TreeSet<>(); + long startTs = Long.MAX_VALUE; + long endTs = Long.MIN_VALUE; + for (TbelCfTsRollingArg arg : args) { + for (TbelCfTsDoubleVal val : arg.getValues()) { + allTimestamps.add(val.getTs()); + } + startTs = Math.min(startTs, arg.getTimeWindow().getStartTs()); + endTs = Math.max(endTs, arg.getTimeWindow().getEndTs()); + } + + List data = new ArrayList<>(); + + int[] lastIndex = new int[args.size()]; + double[] result = new double[args.size()]; + Arrays.fill(result, Double.NaN); + + for (long ts : allTimestamps) { + for (int i = 0; i < args.size(); i++) { + var arg = args.get(i); + var values = arg.getValues(); + while (lastIndex[i] < values.size() && values.get(lastIndex[i]).getTs() <= ts) { + result[i] = values.get(lastIndex[i]).getValue(); + lastIndex[i]++; + } + } + if (timeWindow == null || timeWindow.matches(ts)) { + if (ignoreNaN) { + boolean skip = false; + for (int i = 0; i < args.size(); i++) { + if (Double.isNaN(result[i])) { + skip = true; + break; + } + } + if (!skip) { + data.add(new TbelCfTsMultiDoubleVal(ts, Arrays.copyOf(result, result.length))); + } + } else { + data.add(new TbelCfTsMultiDoubleVal(ts, Arrays.copyOf(result, result.length))); + } + } + } + + return new TbelCfTsRollingData(timeWindow != null ? timeWindow : new TbTimeWindow(startTs, endTs), data); + } + + + @JsonIgnore + public int getSize() { + return values.size(); + } + + @Override + public Iterator iterator() { + return values.iterator(); + } + + @Override + public String getType() { + return "TS_ROLLING"; + } + +} diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingData.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingData.java new file mode 100644 index 0000000000..646e826915 --- /dev/null +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingData.java @@ -0,0 +1,61 @@ +/** + * 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. + */ +package org.thingsboard.script.api.tbel; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Getter; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.function.Consumer; + +import static org.thingsboard.script.api.tbel.TbelCfTsDoubleVal.OBJ_SIZE; + +public class TbelCfTsRollingData implements TbelCfObject, Iterable { + + @Getter + private final TbTimeWindow timeWindow; + @Getter + private final List values; + + public TbelCfTsRollingData(TbTimeWindow timeWindow, List values) { + this.timeWindow = timeWindow; + this.values = Collections.unmodifiableList(values); + } + + @Override + public long memorySize() { + return 12 + values.size() * OBJ_SIZE; + } + + @JsonIgnore + public List getValue() { + return values; + } + + @JsonIgnore + public int getSize() { + return values.size(); + } + + @Override + public Iterator iterator() { + return values.iterator(); + } + +} diff --git a/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java index 72d70e1d92..e3860ed89a 100644 --- a/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java +++ b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java @@ -442,8 +442,9 @@ public class TbUtilsTest { @Test public void parsDouble() { - String doubleValStr = "1729.1729"; - Assertions.assertEquals(java.util.Optional.of(doubleVal).get(), TbUtils.parseDouble(doubleValStr)); + String doubleValStr = "1.1428250947E8"; + Assertions.assertEquals(Double.parseDouble(doubleValStr), TbUtils.parseDouble(doubleValStr)); + doubleValStr = "1729.1729"; Assertions.assertEquals(0, Double.compare(doubleVal, TbUtils.parseHexToDouble(longValHex))); Assertions.assertEquals(0, Double.compare(doubleValRev, TbUtils.parseHexToDouble(longValHex, false))); Assertions.assertEquals(0, Double.compare(doubleVal, TbUtils.parseBigEndianHexToDouble(longValHex))); @@ -930,7 +931,13 @@ public class TbUtilsTest { @Test public void isDecimal_Test() { Assertions.assertEquals(10, TbUtils.isDecimal("4567039")); + Assertions.assertEquals(10, TbUtils.isDecimal("1.1428250947E8")); + Assertions.assertEquals(10, TbUtils.isDecimal("123.45")); + Assertions.assertEquals(10, TbUtils.isDecimal("-1.23E-4")); + Assertions.assertEquals(10, TbUtils.isDecimal("1E5")); Assertions.assertEquals(-1, TbUtils.isDecimal("C100110")); + Assertions.assertEquals(-1, TbUtils.isDecimal("abc")); + Assertions.assertEquals(-1, TbUtils.isDecimal(null)); } @Test @@ -1102,7 +1109,7 @@ public class TbUtilsTest { String validInput = Base64.getEncoder().encodeToString(new byte[]{1, 2, 3, 4, 5}); ExecutionArrayList actual = TbUtils.base64ToBytesList(ctx, validInput); ExecutionArrayList expected = new ExecutionArrayList<>(ctx); - expected.addAll(List.of((byte) 1, (byte)2, (byte)3, (byte)4, (byte)5)); + expected.addAll(List.of((byte) 1, (byte) 2, (byte) 3, (byte) 4, (byte) 5)); Assertions.assertEquals(expected, actual); String emptyInput = Base64.getEncoder().encodeToString(new byte[]{}); @@ -1116,6 +1123,7 @@ public class TbUtilsTest { TbUtils.base64ToBytesList(ctx, null); }); } + @Test public void bytesToHex_Test() { byte[] bb = {(byte) 0xBB, (byte) 0xAA}; @@ -1129,6 +1137,19 @@ public class TbUtilsTest { Assertions.assertEquals(expected, actual); } + @Test + public void toInt() { + Assertions.assertEquals(1729, TbUtils.toInt(doubleVal)); + Assertions.assertEquals(13, TbUtils.toInt(12.8)); + Assertions.assertEquals(28, TbUtils.toInt(28.0)); + } + + @Test + public void isNaN() { + Assertions.assertFalse(TbUtils.isNaN(doubleVal)); + Assertions.assertTrue(TbUtils.isNaN(Double.NaN)); + } + private static List toList(byte[] data) { List result = new ArrayList<>(data.length); for (Byte b : data) { diff --git a/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArgTest.java b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArgTest.java new file mode 100644 index 0000000000..69eba2fab2 --- /dev/null +++ b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArgTest.java @@ -0,0 +1,213 @@ +/** + * 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. + */ +package org.thingsboard.script.api.tbel; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.common.util.JacksonUtil; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; + +public class TbelCfTsRollingArgTest { + + private final long ts = System.currentTimeMillis(); + + private TbelCfTsRollingArg rollingArg; + + @BeforeEach + void setUp() { + rollingArg = new TbelCfTsRollingArg( + new TbTimeWindow(ts - 30000, ts - 10), + List.of( + new TbelCfTsDoubleVal(ts - 10, Double.NaN), + new TbelCfTsDoubleVal(ts - 20, 2.0), + new TbelCfTsDoubleVal(ts - 30, 8.0), + new TbelCfTsDoubleVal(ts - 40, Double.NaN), + new TbelCfTsDoubleVal(ts - 50, 3.0), + new TbelCfTsDoubleVal(ts - 60, 9.0), + new TbelCfTsDoubleVal(ts - 70, Double.NaN) + ) + ); + } + + @Test + void testMax() { + assertThat(rollingArg.max()).isEqualTo(9.0); + assertThat(rollingArg.max(false)).isNaN(); + } + + @Test + void testMin() { + assertThat(rollingArg.min()).isEqualTo(2.0); + assertThat(rollingArg.min(false)).isNaN(); + } + + @Test + void testMean() { + assertThat(rollingArg.mean()).isEqualTo(5.5); + assertThat(rollingArg.mean(false)).isNaN(); + } + + @Test + void testStd() { + assertThat(rollingArg.std()).isCloseTo(3.0413812651491097, within(0.001)); + assertThat(rollingArg.std(false)).isNaN(); + } + + @Test + void testMedian() { + assertThat(rollingArg.median()).isEqualTo(5.5); + assertThat(rollingArg.median(false)).isNaN(); + } + + @Test + void testCount() { + assertThat(rollingArg.count()).isEqualTo(4); + assertThat(rollingArg.count(false)).isEqualTo(7); + } + + @Test + void testLast() { + assertThat(rollingArg.last()).isEqualTo(9.0); + assertThat(rollingArg.last(false)).isNaN(); + } + + @Test + void testFirst() { + assertThat(rollingArg.first()).isEqualTo(2.0); + assertThat(rollingArg.first(false)).isNaN(); + } + + @Test + void testFirstAndLastWhenOnlyNaNAndIgnoreNaNIsFalse() { + assertThat(rollingArg.first()).isEqualTo(2.0); + rollingArg = new TbelCfTsRollingArg( + new TbTimeWindow(ts - 30000, ts - 10), + List.of( + new TbelCfTsDoubleVal(ts - 10, Double.NaN), + new TbelCfTsDoubleVal(ts - 40, Double.NaN), + new TbelCfTsDoubleVal(ts - 70, Double.NaN) + ) + ); + assertThatThrownBy(rollingArg::first).isInstanceOf(IllegalArgumentException.class).hasMessage("Rolling argument values are empty."); + assertThatThrownBy(rollingArg::last).isInstanceOf(IllegalArgumentException.class).hasMessage("Rolling argument values are empty."); + } + + @Test + void testSum() { + assertThat(rollingArg.sum()).isEqualTo(22.0); + assertThat(rollingArg.sum(false)).isNaN(); + } + + @Test + void testEmptyValues() { + rollingArg = new TbelCfTsRollingArg(new TbTimeWindow(0, 10), List.of()); + assertThatThrownBy(rollingArg::sum).isInstanceOf(IllegalArgumentException.class).hasMessage("Rolling argument values are empty."); + assertThatThrownBy(rollingArg::max).isInstanceOf(IllegalArgumentException.class).hasMessage("Rolling argument values are empty."); + assertThatThrownBy(rollingArg::min).isInstanceOf(IllegalArgumentException.class).hasMessage("Rolling argument values are empty."); + assertThatThrownBy(rollingArg::mean).isInstanceOf(IllegalArgumentException.class).hasMessage("Rolling argument values are empty."); + assertThatThrownBy(rollingArg::std).isInstanceOf(IllegalArgumentException.class).hasMessage("Rolling argument values are empty."); + assertThatThrownBy(rollingArg::median).isInstanceOf(IllegalArgumentException.class).hasMessage("Rolling argument values are empty."); + assertThatThrownBy(rollingArg::first).isInstanceOf(IllegalArgumentException.class).hasMessage("Rolling argument values are empty."); + assertThatThrownBy(rollingArg::last).isInstanceOf(IllegalArgumentException.class).hasMessage("Rolling argument values are empty."); + } + + @Test + public void merge_two_rolling_args_ts_match_test() { + TbTimeWindow tw = new TbTimeWindow(0, 60000); + TbelCfTsRollingArg arg1 = new TbelCfTsRollingArg(tw, Arrays.asList(new TbelCfTsDoubleVal(1000, 1), new TbelCfTsDoubleVal(5000, 2), new TbelCfTsDoubleVal(15000, 3))); + TbelCfTsRollingArg arg2 = new TbelCfTsRollingArg(tw, Arrays.asList(new TbelCfTsDoubleVal(1000, 11), new TbelCfTsDoubleVal(5000, 12), new TbelCfTsDoubleVal(15000, 13))); + + var result = arg1.merge(arg2); + Assertions.assertEquals(3, result.getSize()); + Assertions.assertNotNull(result.getValues()); + Assertions.assertNotNull(result.getValues().get(0)); + Assertions.assertEquals(1000L, result.getValues().get(0).getTs()); + Assertions.assertEquals(1, result.getValues().get(0).getValues()[0]); + Assertions.assertEquals(11, result.getValues().get(0).getValues()[1]); + } + + @Test + public void merge_two_rolling_args_with_timewindow_test() { + TbTimeWindow tw = new TbTimeWindow(0, 60000); + TbelCfTsRollingArg arg1 = new TbelCfTsRollingArg(tw, Arrays.asList(new TbelCfTsDoubleVal(1000, 1), new TbelCfTsDoubleVal(5000, 2), new TbelCfTsDoubleVal(15000, 3))); + TbelCfTsRollingArg arg2 = new TbelCfTsRollingArg(tw, Arrays.asList(new TbelCfTsDoubleVal(1000, 11), new TbelCfTsDoubleVal(5000, 12), new TbelCfTsDoubleVal(15000, 13))); + + var result = arg1.merge(arg2, Collections.singletonMap("timeWindow", new TbTimeWindow(0, 10000))); + Assertions.assertEquals(2, result.getSize()); + Assertions.assertNotNull(result.getValues()); + Assertions.assertNotNull(result.getValues().get(0)); + Assertions.assertEquals(1000L, result.getValues().get(0).getTs()); + Assertions.assertEquals(1, result.getValues().get(0).getValues()[0]); + Assertions.assertEquals(11, result.getValues().get(0).getValues()[1]); + + result = arg1.merge(arg2, Collections.singletonMap("timeWindow", Map.of("startTs", 0L, "endTs", 10000))); + Assertions.assertEquals(2, result.getSize()); + Assertions.assertNotNull(result.getValues()); + Assertions.assertNotNull(result.getValues().get(0)); + Assertions.assertEquals(1000L, result.getValues().get(0).getTs()); + Assertions.assertEquals(1, result.getValues().get(0).getValues()[0]); + Assertions.assertEquals(11, result.getValues().get(0).getValues()[1]); + } + + @Test + public void merge_two_rolling_args_ts_mismatch_default_test() { + TbTimeWindow tw = new TbTimeWindow(0, 60000); + TbelCfTsRollingArg arg1 = new TbelCfTsRollingArg(tw, Arrays.asList(new TbelCfTsDoubleVal(100, 1), new TbelCfTsDoubleVal(5000, 2), new TbelCfTsDoubleVal(15000, 3))); + TbelCfTsRollingArg arg2 = new TbelCfTsRollingArg(tw, Arrays.asList(new TbelCfTsDoubleVal(200, 11), new TbelCfTsDoubleVal(5000, 12), new TbelCfTsDoubleVal(15000, 13))); + + var result = arg1.merge(arg2); + Assertions.assertEquals(3, result.getSize()); + Assertions.assertNotNull(result.getValues()); + + TbelCfTsMultiDoubleVal item0 = result.getValues().get(0); + Assertions.assertNotNull(item0); + Assertions.assertEquals(200L, item0.getTs()); + Assertions.assertEquals(1, item0.getValues()[0]); + Assertions.assertEquals(11, item0.getValues()[1]); + } + + @Test + public void merge_two_rolling_args_ts_mismatch_ignore_nan_disabled_test() { + TbTimeWindow tw = new TbTimeWindow(0, 60000); + TbelCfTsRollingArg arg1 = new TbelCfTsRollingArg(tw, Arrays.asList(new TbelCfTsDoubleVal(100, 1), new TbelCfTsDoubleVal(5000, 2), new TbelCfTsDoubleVal(15000, 3))); + TbelCfTsRollingArg arg2 = new TbelCfTsRollingArg(tw, Arrays.asList(new TbelCfTsDoubleVal(200, 11), new TbelCfTsDoubleVal(5000, 12), new TbelCfTsDoubleVal(15000, 13))); + + var result = arg1.merge(arg2, Collections.singletonMap("ignoreNaN", false)); + Assertions.assertEquals(4, result.getSize()); + Assertions.assertNotNull(result.getValues()); + + TbelCfTsMultiDoubleVal item0 = result.getValues().get(0); + Assertions.assertNotNull(item0); + Assertions.assertEquals(100L, item0.getTs()); + Assertions.assertEquals(1, item0.getValues()[0]); + Assertions.assertEquals(Double.NaN, item0.getValues()[1]); + + TbelCfTsMultiDoubleVal item1 = result.getValues().get(1); + Assertions.assertEquals(200L, item1.getTs()); + Assertions.assertEquals(1, item1.getValues()[0]); + Assertions.assertEquals(11, item1.getValues()[1]); + } + +} \ No newline at end of file diff --git a/common/stats/src/main/java/org/thingsboard/server/common/stats/DefaultCounter.java b/common/stats/src/main/java/org/thingsboard/server/common/stats/DefaultCounter.java index ef9041fab5..126d4dac95 100644 --- a/common/stats/src/main/java/org/thingsboard/server/common/stats/DefaultCounter.java +++ b/common/stats/src/main/java/org/thingsboard/server/common/stats/DefaultCounter.java @@ -41,6 +41,10 @@ public class DefaultCounter { return aiCounter.get(); } + public int getAndClear() { + return aiCounter.getAndSet(0); + } + public void add(int delta){ aiCounter.addAndGet(delta); micrometerCounter.increment(delta); diff --git a/common/stats/src/main/java/org/thingsboard/server/common/stats/StatsType.java b/common/stats/src/main/java/org/thingsboard/server/common/stats/StatsType.java index a21b52eaf4..90bf1bec32 100644 --- a/common/stats/src/main/java/org/thingsboard/server/common/stats/StatsType.java +++ b/common/stats/src/main/java/org/thingsboard/server/common/stats/StatsType.java @@ -20,9 +20,11 @@ public enum StatsType { CORE("core"), TRANSPORT("transport"), JS_INVOKE("jsInvoke"), + TBEL_INVOKE("tbelInvoke"), RATE_EXECUTOR("rateExecutor"), HOUSEKEEPER("housekeeper"), - EDGE("edge"); + EDGE("edge"), + EDQS("edqs"); private final String name; diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/secure/LwM2mCredentialsSecurityInfoValidator.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/secure/LwM2mCredentialsSecurityInfoValidator.java index f518d0176f..783fe62c80 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/secure/LwM2mCredentialsSecurityInfoValidator.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/secure/LwM2mCredentialsSecurityInfoValidator.java @@ -88,7 +88,7 @@ public class LwM2mCredentialsSecurityInfoValidator { } TbLwM2MSecurityInfo securityInfo = resultSecurityStore[0]; - if (securityInfo.getSecurityMode() == null) { + if (securityInfo != null && securityInfo.getSecurityMode() == null) { throw new LwM2MAuthException(); } return securityInfo; diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java index 71290ef0b0..795f40aa20 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java @@ -18,6 +18,9 @@ package org.thingsboard.server.transport.lwm2m.server; import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.eclipse.californium.core.CoapResource; +import org.eclipse.californium.core.CoapServer; +import org.eclipse.californium.core.config.CoapConfig; import org.eclipse.californium.elements.config.Configuration; import org.eclipse.californium.scandium.config.DtlsConfig; import org.eclipse.californium.scandium.dtls.cipher.CipherSuite; @@ -58,6 +61,7 @@ import static org.eclipse.californium.scandium.dtls.cipher.CipherSuite.TLS_PSK_W import static org.eclipse.californium.scandium.dtls.cipher.CipherSuite.TLS_PSK_WITH_AES_128_CCM_8; import static org.thingsboard.server.transport.lwm2m.server.LwM2MNetworkConfig.getCoapConfig; import static org.thingsboard.server.transport.lwm2m.server.ota.DefaultLwM2MOtaUpdateService.FIRMWARE_UPDATE_COAP_RESOURCE; +import static org.thingsboard.server.transport.lwm2m.server.ota.DefaultLwM2MOtaUpdateService.SOFTWARE_UPDATE_COAP_RESOURCE; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.setDtlsConnectorConfigCidLength; @Slf4j @@ -85,14 +89,6 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService { @AfterStartUp(order = AfterStartUp.AFTER_TRANSPORT_SERVICE) public void init() { this.server = getLhServer(); - /* - * Add a resource to the server. - * CoapResource -> - * path = FW_PACKAGE or SW_PACKAGE - * nameFile = "BC68JAR01A09_TO_BC68JAR01A10.bin" - * "coap://host:port/{path}/{token}/{nameFile}" - */ - new LwM2mTransportCoapResource(otaPackageDataCache, FIRMWARE_UPDATE_COAP_RESOURCE); this.context.setServer(server); this.startLhServer(); } @@ -129,6 +125,7 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService { /* Set securityStore with new registrationStore */ builder.setSecurityStore(securityStore); builder.setRegistrationStore(registrationStore); + builder.setAuthorizer(authorizer); // Create Californium Endpoints Provider: @@ -168,7 +165,7 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService { serverCoapConfig.setTransient(DtlsConfig.DTLS_CONNECTION_ID_LENGTH); if (config.getDtlsCidLength() != null) { - setDtlsConnectorConfigCidLength( serverCoapConfig, config.getDtlsCidLength()); + setDtlsConnectorConfigCidLength(serverCoapConfig, config.getDtlsCidLength()); } /* Create DTLS Config */ @@ -191,7 +188,18 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService { // Create LWM2M server builder.setEndpointsProviders(endpointsBuilder.build()); - return builder.build(); + LeshanServer leshanServer = builder.build(); + CoapServer coapServer = ((CaliforniumServerEndpointsProvider) (leshanServer.getEndpointsProvider()).toArray()[0]).getCoapServer(); + if (coapServer != null) { + CoapResource root = (CoapResource) coapServer.getRoot(); + if (root == null) { + root = new CoapResource(""); + coapServer.add(root); + } + root.add(new LwM2mTransportCoapResource(otaPackageDataCache, FIRMWARE_UPDATE_COAP_RESOURCE, serverCoapConfig.get(CoapConfig.PREFERRED_BLOCK_SIZE), serverCoapConfig.get(CoapConfig.MAX_RESOURCE_BODY_SIZE))); + root.add(new LwM2mTransportCoapResource(otaPackageDataCache, SOFTWARE_UPDATE_COAP_RESOURCE,serverCoapConfig.get(CoapConfig.PREFERRED_BLOCK_SIZE), serverCoapConfig.get(CoapConfig.MAX_RESOURCE_BODY_SIZE))); + } + return leshanServer; } private void setServerWithCredentials(LeshanServerBuilder builder) { diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportCoapResource.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportCoapResource.java index 7e9841ea9f..c46b57fb3f 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportCoapResource.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportCoapResource.java @@ -34,16 +34,21 @@ import java.util.concurrent.atomic.AtomicInteger; import static org.thingsboard.server.transport.lwm2m.server.ota.DefaultLwM2MOtaUpdateService.FIRMWARE_UPDATE_COAP_RESOURCE; import static org.thingsboard.server.transport.lwm2m.server.ota.DefaultLwM2MOtaUpdateService.SOFTWARE_UPDATE_COAP_RESOURCE; +import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.calculateSzx; @Slf4j public class LwM2mTransportCoapResource extends AbstractLwM2mTransportResource { private final ConcurrentMap tokenToObserveRelationMap = new ConcurrentHashMap<>(); private final ConcurrentMap tokenToObserveNotificationSeqMap = new ConcurrentHashMap<>(); private final OtaPackageDataCache otaPackageDataCache; + private final int chunkSize; + private final int maxResourceBodySize; - public LwM2mTransportCoapResource(OtaPackageDataCache otaPackageDataCache, String name) { + public LwM2mTransportCoapResource(OtaPackageDataCache otaPackageDataCache, String name, int chunkSize, int maxResourceBodySize) { super(name); this.otaPackageDataCache = otaPackageDataCache; + this.chunkSize = chunkSize; + this.maxResourceBodySize = maxResourceBodySize; this.setObservable(true); // enable observing this.addObserver(new CoapResourceObserver()); } @@ -136,22 +141,29 @@ public class LwM2mTransportCoapResource extends AbstractLwM2mTransportResource { String idStr = exchange.getRequestOptions().getUriPath().get(exchange.getRequestOptions().getUriPath().size() - 1 ); UUID currentId = UUID.fromString(idStr); + log.info("Start Read ota data (path): [{}]", exchange.getRequestOptions().getUriPath().toString()); Response response = new Response(CoAP.ResponseCode.CONTENT); byte[] otaData = this.getOtaData(currentId); if (otaData != null && otaData.length > 0) { - log.debug("Read ota data (length): [{}]", otaData.length); - response.setPayload(otaData); - if (exchange.getRequestOptions().getBlock2() != null) { - int chunkSize = exchange.getRequestOptions().getBlock2().getSzx(); - boolean lastFlag = otaData.length <= chunkSize; + if (otaData.length <= this.maxResourceBodySize) { + log.info("Read ota data (length): [{}]", otaData.length); + response.setPayload(otaData); + int chunkSize = calculateSzx(this.chunkSize); + if (exchange.getRequestOptions().hasBlock2()) { + chunkSize = exchange.getRequestOptions().getBlock2().getSzx(); + } else if (exchange.getRequestOptions().hasBlock1()) { + chunkSize = exchange.getRequestOptions().getBlock1().getSzx(); + } + log.info("With block2 Send currentId: [{}], length: [{}], chunkSize [{}], moreFlag [{}]", currentId.toString(), otaData.length, chunkSize, false); + boolean lastFlag = otaData.length <= this.chunkSize; response.getOptions().setBlock2(chunkSize, lastFlag, 0); - log.trace("With block2 Send currentId: [{}], length: [{}], chunkSize [{}], moreFlag [{}]", currentId.toString(), otaData.length, chunkSize, lastFlag); + response.setType(CoAP.Type.CON); + exchange.respond(response); } else { - log.trace("With block1 Send currentId: [{}], length: [{}], ", currentId.toString(), otaData.length); + log.info("Ota package size: [{}] is larger than server's MAX_RESOURCE_BODY_SIZE [{}]", otaData.length, this.maxResourceBodySize); } - exchange.respond(response); } else { - log.trace("Ota packaged currentId: [{}] is not found.", currentId.toString()); + log.info("Ota packaged currentId: [{}] is not found.", currentId.toString()); } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java index 882fe4fa04..6a4bc645d4 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java @@ -141,7 +141,7 @@ public class LwM2mClientContextImpl implements LwM2mClientContext { } oldSession = client.getSession(); TbLwM2MSecurityInfo securityInfo = securityStore.getTbLwM2MSecurityInfoByEndpoint(client.getEndpoint()); - if (securityInfo.getSecurityMode() != null) { + if (securityInfo != null && securityInfo.getSecurityMode() != null) { if (SecurityMode.X509.equals(securityInfo.getSecurityMode())) { securityStore.registerX509(registration.getEndpoint(), registration.getId()); } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2MTransportUtil.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2MTransportUtil.java index 438bb752aa..3ee83766fe 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2MTransportUtil.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2MTransportUtil.java @@ -393,4 +393,11 @@ public class LwM2MTransportUtil { serverCoapConfig.set(DTLS_CONNECTION_ID_NODE_ID, null); } } + + public static int calculateSzx(int size) { + if (size < 16 || size > 1024 || (size & (size - 1)) != 0) { + throw new IllegalArgumentException("Size must be a power of 2 between 16 and 1024."); + } + return (int) (Math.log(size / 16) / Math.log(2)); + } } diff --git a/common/util/src/main/java/org/thingsboard/common/util/DebugModeUtil.java b/common/util/src/main/java/org/thingsboard/common/util/DebugModeUtil.java index d50ecfc5e0..c4b062a0d8 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/DebugModeUtil.java +++ b/common/util/src/main/java/org/thingsboard/common/util/DebugModeUtil.java @@ -57,4 +57,14 @@ public final class DebugModeUtil { return debugSettings != null && nodeConnections != null && debugSettings.isFailuresEnabled() && nodeConnections.contains(TbNodeConnectionType.FAILURE); } } + + public static boolean isDebugFailuresAvailable(HasDebugSettings debugSettingsAware) { + if (isDebugAllAvailable(debugSettingsAware)) { + return true; + } else { + var debugSettings = debugSettingsAware.getDebugSettings(); + return debugSettings != null && debugSettings.isFailuresEnabled(); + } + } + } diff --git a/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java b/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java index 008158246e..0f1a56cb17 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java +++ b/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java @@ -36,6 +36,9 @@ public class DonAsynchron { FutureCallback callback = new FutureCallback() { @Override public void onSuccess(T result) { + if (onSuccess == null) { + return; + } try { onSuccess.accept(result); } catch (Throwable th) { @@ -45,6 +48,9 @@ public class DonAsynchron { @Override public void onFailure(Throwable t) { + if (onFailure == null) { + return; + } onFailure.accept(t); } }; diff --git a/common/util/src/main/java/org/thingsboard/common/util/ExceptionUtil.java b/common/util/src/main/java/org/thingsboard/common/util/ExceptionUtil.java index e08a8de30a..c93ec09bbf 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/ExceptionUtil.java +++ b/common/util/src/main/java/org/thingsboard/common/util/ExceptionUtil.java @@ -64,4 +64,14 @@ public class ExceptionUtil { } } } + + public static String getMessage(Throwable t) { + String message = t.getMessage(); + if (StringUtils.isNotEmpty(message)) { + return message; + } else { + return t.getClass().getSimpleName(); + } + } + } diff --git a/common/util/src/main/java/org/thingsboard/common/util/LinkedHashMapRemoveEldest.java b/common/util/src/main/java/org/thingsboard/common/util/LinkedHashMapRemoveEldest.java index cd902e3cdb..f570017e31 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/LinkedHashMapRemoveEldest.java +++ b/common/util/src/main/java/org/thingsboard/common/util/LinkedHashMapRemoveEldest.java @@ -34,10 +34,10 @@ import java.util.function.BiConsumer; @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) public class LinkedHashMapRemoveEldest extends LinkedHashMap { - final long maxEntries; + final int maxEntries; final BiConsumer removalConsumer; - public LinkedHashMapRemoveEldest(long maxEntries, BiConsumer removalConsumer) { + public LinkedHashMapRemoveEldest(int maxEntries, BiConsumer removalConsumer) { this.maxEntries = maxEntries; this.removalConsumer = removalConsumer; } diff --git a/common/util/src/main/java/org/thingsboard/common/util/NoOpFutureCallback.java b/common/util/src/main/java/org/thingsboard/common/util/NoOpFutureCallback.java new file mode 100644 index 0000000000..176d949c95 --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/NoOpFutureCallback.java @@ -0,0 +1,35 @@ +/** + * 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. + */ +package org.thingsboard.common.util; + +import com.google.common.util.concurrent.FutureCallback; + +public enum NoOpFutureCallback implements FutureCallback { + + INSTANCE; + + @Override + public void onSuccess(Object result) {} + + @Override + public void onFailure(Throwable t) {} + + @SuppressWarnings("unchecked") + public static FutureCallback instance() { + return (FutureCallback) INSTANCE; + } + +} diff --git a/common/util/src/test/java/org/thingsboard/common/util/LinkedHashMapRemoveEldestTest.java b/common/util/src/test/java/org/thingsboard/common/util/LinkedHashMapRemoveEldestTest.java index 0fb12081c9..9ddc658fa5 100644 --- a/common/util/src/test/java/org/thingsboard/common/util/LinkedHashMapRemoveEldestTest.java +++ b/common/util/src/test/java/org/thingsboard/common/util/LinkedHashMapRemoveEldestTest.java @@ -27,10 +27,10 @@ import static org.hamcrest.MatcherAssert.assertThat; public class LinkedHashMapRemoveEldestTest { - public static final long MAX_ENTRIES = 10L; - long removeCount = 0; + public static final int MAX_ENTRIES = 10; + int removeCount = 0; - void removalConsumer(Long id, String name) { + void removalConsumer(Integer id, String name) { removeCount++; assertThat(id, is(Matchers.lessThan(MAX_ENTRIES))); assertThat(name, is(id.toString())); @@ -39,7 +39,7 @@ public class LinkedHashMapRemoveEldestTest { @Test public void givenMap_whenOverSized_thenVerifyRemovedEldest() { //given - LinkedHashMapRemoveEldest map = + LinkedHashMapRemoveEldest map = new LinkedHashMapRemoveEldest<>(MAX_ENTRIES, this::removalConsumer); assertThat(map.getMaxEntries(), is(MAX_ENTRIES)); @@ -49,14 +49,14 @@ public class LinkedHashMapRemoveEldestTest { assertThat(map.size(), is(0)); //when - for (long i = 0; i < MAX_ENTRIES * 2; i++) { + for (int i = 0; i < MAX_ENTRIES * 2; i++) { map.put(i, String.valueOf(i)); } //then - assertThat((long) map.size(), is(MAX_ENTRIES)); + assertThat( map.size(), is(MAX_ENTRIES)); assertThat(removeCount, is(MAX_ENTRIES)); - for (long i = MAX_ENTRIES; i < MAX_ENTRIES * 2; i++) { + for (int i = MAX_ENTRIES; i < MAX_ENTRIES * 2; i++) { assertThat(map.get(i), is(String.valueOf(i))); } } diff --git a/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultClusterVersionControlService.java b/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultClusterVersionControlService.java index 737de124c8..0f8969f9a4 100644 --- a/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultClusterVersionControlService.java +++ b/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultClusterVersionControlService.java @@ -175,7 +175,7 @@ public class DefaultClusterVersionControlService extends TbApplicationEventListe } } } - consumer.subscribe(event.getPartitionsMap().values().stream().findAny().orElse(Collections.emptySet())); + consumer.subscribe(event.getNewPartitions().values().stream().findAny().orElse(Collections.emptySet())); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/Dao.java b/dao/src/main/java/org/thingsboard/server/dao/Dao.java index a96e401869..72883c55ef 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/Dao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/Dao.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.fields.EntityFields; import org.thingsboard.server.common.data.id.TenantId; import java.util.Collection; @@ -45,6 +46,12 @@ public interface Dao { List findIdsByTenantIdAndIdOffset(TenantId tenantId, UUID idOffset, int limit); - default EntityType getEntityType() { return null; } + default List findNextBatch(UUID id, int batchSize) { + throw new UnsupportedOperationException(); + } + + default EntityType getEntityType() { + return null; + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java b/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java index c9dcc87fa9..43b651bb24 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java +++ b/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java @@ -31,6 +31,7 @@ import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.dao.model.ToData; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Comparator; @@ -85,6 +86,10 @@ public abstract class DaoUtil { return toPageable(pageLink, Collections.emptyMap(), sortOrders); } + public static Pageable toPageable(PageLink pageLink, String... sortColumns) { + return toPageable(pageLink, Collections.emptyMap(), Arrays.stream(sortColumns).map(column -> new SortOrder(column, SortOrder.Direction.ASC)).toList(), false); + } + public static Pageable toPageable(PageLink pageLink, Map columnMap, List sortOrders) { return toPageable(pageLink, columnMap, sortOrders, true); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/TenantEntityDao.java b/dao/src/main/java/org/thingsboard/server/dao/TenantEntityDao.java index 1ebf148b1c..c295108a47 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/TenantEntityDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/TenantEntityDao.java @@ -16,8 +16,17 @@ package org.thingsboard.server.dao; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; -public interface TenantEntityDao { +public interface TenantEntityDao { + + default Long countByTenantId(TenantId tenantId) { + throw new UnsupportedOperationException(); + } + + default PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + throw new UnsupportedOperationException(); + } - Long countByTenantId(TenantId tenantId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/alarm/AlarmDao.java b/dao/src/main/java/org/thingsboard/server/dao/alarm/AlarmDao.java index b0439b6407..0351490f70 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/alarm/AlarmDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/alarm/AlarmDao.java @@ -106,7 +106,7 @@ public interface AlarmDao extends Dao { AlarmApiCallResult unassignAlarm(TenantId tenantId, AlarmId alarmId, long unassignTime); - long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query); + long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query, Collection orderedEntityIds); PageData findTenantAlarmTypes(UUID tenantId, PageLink pageLink); diff --git a/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmService.java b/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmService.java index fcfd5e0a8d..23e05e1bfa 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmService.java @@ -351,8 +351,13 @@ public class BaseAlarmService extends AbstractCachedEntityService orderedEntityIds) { validateId(tenantId, id -> INCORRECT_TENANT_ID + id); - return alarmDao.countAlarmsByQuery(tenantId, customerId, query); + return alarmDao.countAlarmsByQuery(tenantId, customerId, query, orderedEntityIds); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java index 3ac2877fc0..36700ff59f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.asset; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetInfo; import org.thingsboard.server.common.data.id.AssetId; @@ -36,7 +37,7 @@ import java.util.UUID; * The Interface AssetDao. * */ -public interface AssetDao extends Dao, TenantEntityDao, ExportableEntityDao { +public interface AssetDao extends Dao, TenantEntityDao, ExportableEntityDao { /** * Find asset info by id. @@ -103,6 +104,16 @@ public interface AssetDao extends Dao, TenantEntityDao, ExportableEntityD */ PageData findAssetInfosByTenantIdAndAssetProfileId(UUID tenantId, UUID assetProfileId, PageLink pageLink); + /** + * Find asset ids by tenantId, assetProfileId and page link. + * + * @param tenantId the tenantId + * @param assetProfileId the assetProfileId + * @param pageLink the page link + * @return the list of asset objects + */ + PageData findAssetIdsByTenantIdAndAssetProfileId(UUID tenantId, UUID assetProfileId, PageLink pageLink); + /** * Find assets by tenantId and assets Ids. * @@ -226,4 +237,7 @@ public interface AssetDao extends Dao, TenantEntityDao, ExportableEntityD PageData findAssetsByTenantIdAndEdgeIdAndType(UUID tenantId, UUID edgeId, String type, PageLink pageLink); PageData> getAllAssetTypes(PageLink pageLink); + + PageData findProfileEntityIdInfos(PageLink pageLink); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java index 3ba9160975..6bf44e4da1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java @@ -148,12 +148,11 @@ public class AssetProfileServiceImpl extends CachedVersionedEntityService findProfileEntityIdInfos(PageLink pageLink) { + log.trace("Executing findProfileEntityIdInfos, pageLink [{}]", pageLink); + validatePageLink(pageLink); + return assetDao.findProfileEntityIdInfos(pageLink); + } + + @Override + public PageData findAssetIdsByTenantIdAndAssetProfileId(TenantId tenantId, AssetProfileId assetProfileId, PageLink pageLink) { + log.trace("Executing findAssetIdsByTenantIdAndAssetProfileId, tenantId [{}], assetProfileId [{}]", tenantId, assetProfileId); + validateId(tenantId, id -> INCORRECT_TENANT_ID + id); + validateId(assetProfileId, id -> INCORRECT_ASSET_PROFILE_ID + id); + validatePageLink(pageLink); + return assetDao.findAssetIdsByTenantIdAndAssetProfileId(tenantId.getId(), assetProfileId.getId(), pageLink); + } + @Override public ListenableFuture> findAssetsByTenantIdAndIdsAsync(TenantId tenantId, List assetIds) { log.trace("Executing findAssetsByTenantIdAndIdsAsync, tenantId [{}], assetIds [{}]", tenantId, assetIds); diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java index 762201f58d..5527d17add 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java @@ -23,10 +23,12 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.util.TbPair; +import org.thingsboard.server.dao.model.sql.AttributeKvEntity; import java.util.Collection; import java.util.List; import java.util.Optional; +import java.util.UUID; /** * @author Andrew Shvayka @@ -45,6 +47,8 @@ public interface AttributesDao { List>> removeAllWithVersions(TenantId tenantId, EntityId entityId, AttributeScope attributeScope, List keys); + List findNextBatch(UUID entityId, int attributeType, int attributeKey, int batchSize); + List findAllKeysByDeviceProfileId(TenantId tenantId, DeviceProfileId deviceProfileId); List findAllKeysByEntityIds(TenantId tenantId, List entityIds); diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java index 6e3759a991..777a77d054 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java @@ -17,6 +17,8 @@ package org.thingsboard.server.dao.attributes; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.Pair; import org.springframework.beans.factory.annotation.Value; @@ -24,13 +26,18 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.ObjectType; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.edqs.AttributeKv; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.util.TbPair; +import org.thingsboard.server.common.msg.edqs.EdqsService; import org.thingsboard.server.dao.service.Validator; +import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Optional; @@ -45,16 +52,15 @@ import static org.thingsboard.server.dao.attributes.AttributeUtils.validate; @ConditionalOnProperty(prefix = "cache.attributes", value = "enabled", havingValue = "false", matchIfMissing = true) @Primary @Slf4j +@RequiredArgsConstructor public class BaseAttributesService implements AttributesService { + private final AttributesDao attributesDao; + private final EdqsService edqsService; @Value("${sql.attributes.value_no_xss_validation:false}") private boolean valueNoXssValidation; - public BaseAttributesService(AttributesDao attributesDao) { - this.attributesDao = attributesDao; - } - @Override public ListenableFuture> find(TenantId tenantId, EntityId entityId, AttributeScope scope, String attributeKey) { validate(entityId, scope); @@ -98,26 +104,53 @@ public class BaseAttributesService implements AttributesService { public ListenableFuture save(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) { validate(entityId, scope); AttributeUtils.validate(attribute, valueNoXssValidation); - return attributesDao.save(tenantId, entityId, scope, attribute); + return doSave(tenantId, entityId, scope, attribute); } @Override public ListenableFuture> save(TenantId tenantId, EntityId entityId, AttributeScope scope, List attributes) { validate(entityId, scope); AttributeUtils.validate(attributes, valueNoXssValidation); - List> saveFutures = attributes.stream().map(attribute -> attributesDao.save(tenantId, entityId, scope, attribute)).collect(Collectors.toList()); + List> saveFutures = attributes.stream().map(attribute -> doSave(tenantId, entityId, scope, attribute)).collect(Collectors.toList()); return Futures.allAsList(saveFutures); } + private ListenableFuture doSave(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) { + ListenableFuture future = attributesDao.save(tenantId, entityId, scope, attribute); + return Futures.transform(future, version -> { + edqsService.onUpdate(tenantId, ObjectType.ATTRIBUTE_KV, new AttributeKv(entityId, scope, attribute, version)); + return version; + }, MoreExecutors.directExecutor()); + } + @Override public ListenableFuture> removeAll(TenantId tenantId, EntityId entityId, AttributeScope scope, List attributeKeys) { validate(entityId, scope); - return Futures.allAsList(attributesDao.removeAll(tenantId, entityId, scope, attributeKeys)); + List>> futures = attributesDao.removeAllWithVersions(tenantId, entityId, scope, attributeKeys); + return Futures.transform(Futures.allAsList(futures), result -> { + List keys = new ArrayList<>(); + for (TbPair keyVersionPair : result) { + String key = keyVersionPair.getFirst(); + Long version = keyVersionPair.getSecond(); + if (version != null) { + edqsService.onDelete(tenantId, ObjectType.ATTRIBUTE_KV, new AttributeKv(entityId, scope, key, version)); + } + keys.add(key); + } + return keys; + }, MoreExecutors.directExecutor()); } @Override public int removeAllByEntityId(TenantId tenantId, EntityId entityId) { List> deleted = attributesDao.removeAllByEntityId(tenantId, entityId); + deleted.forEach(attribute -> { + AttributeScope scope = attribute.getKey(); + String key = attribute.getValue(); + if (scope != null && key != null) { + edqsService.onDelete(tenantId, ObjectType.ATTRIBUTE_KV, new AttributeKv(entityId, scope, key, Long.MAX_VALUE)); + } + }); return deleted.size(); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java index d9afa69ba7..559828911f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java @@ -24,18 +24,22 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.Pair; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Service; import org.thingsboard.server.cache.TbCacheValueWrapper; import org.thingsboard.server.cache.VersionedTbCache; import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.ObjectType; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.edqs.AttributeKv; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.util.TbPair; +import org.thingsboard.server.common.msg.edqs.EdqsService; import org.thingsboard.server.common.stats.DefaultCounter; import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.dao.cache.CacheExecutorService; @@ -67,6 +71,7 @@ public class CachedAttributesService implements AttributesService { private final AttributesDao attributesDao; private final JpaExecutorService jpaExecutorService; private final CacheExecutorService cacheExecutorService; + private final EdqsService edqsService; private final DefaultCounter hitCounter; private final DefaultCounter missCounter; private final VersionedTbCache cache; @@ -79,11 +84,12 @@ public class CachedAttributesService implements AttributesService { public CachedAttributesService(AttributesDao attributesDao, JpaExecutorService jpaExecutorService, - StatsFactory statsFactory, + @Lazy EdqsService edqsService, StatsFactory statsFactory, CacheExecutorService cacheExecutorService, VersionedTbCache cache) { this.attributesDao = attributesDao; this.jpaExecutorService = jpaExecutorService; + this.edqsService = edqsService; this.cacheExecutorService = cacheExecutorService; this.cache = cache; @@ -237,8 +243,10 @@ public class CachedAttributesService implements AttributesService { private ListenableFuture doSave(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) { ListenableFuture future = attributesDao.save(tenantId, entityId, scope, attribute); - return Futures.transform(future, version -> { - put(entityId, scope, new BaseAttributeKvEntry(((BaseAttributeKvEntry)attribute).getKv(), attribute.getLastUpdateTs(), version)); + return Futures.transform(future, version -> { + BaseAttributeKvEntry attributeKvEntry = new BaseAttributeKvEntry(((BaseAttributeKvEntry) attribute).getKv(), attribute.getLastUpdateTs(), version); + put(entityId, scope, attributeKvEntry); + edqsService.onUpdate(tenantId, ObjectType.ATTRIBUTE_KV, new AttributeKv(entityId, scope, attributeKvEntry, version)); return version; }, cacheExecutor); } @@ -256,7 +264,11 @@ public class CachedAttributesService implements AttributesService { List>> futures = attributesDao.removeAllWithVersions(tenantId, entityId, scope, attributeKeys); return Futures.allAsList(futures.stream().map(future -> Futures.transform(future, keyVersionPair -> { String key = keyVersionPair.getFirst(); - cache.evict(new AttributeCacheKey(scope, entityId, key), keyVersionPair.getSecond()); + Long version = keyVersionPair.getSecond(); + cache.evict(new AttributeCacheKey(scope, entityId, key), version); + if (version != null) { + edqsService.onDelete(tenantId, ObjectType.ATTRIBUTE_KV, new AttributeKv(entityId, scope, key, version)); + } return key; }, cacheExecutor)).collect(Collectors.toList())); } @@ -269,6 +281,8 @@ public class CachedAttributesService implements AttributesService { String key = deleted.getValue(); if (scope != null && key != null) { cache.evict(new AttributeCacheKey(scope, entityId, key)); + // using version as Long.MAX_VALUE because we expect that the entity is deleted and there won't be any attributes after this + edqsService.onDelete(tenantId, ObjectType.ATTRIBUTE_KV, new AttributeKv(entityId, scope, key, Long.MAX_VALUE)); } }); return result.size(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java new file mode 100644 index 0000000000..dceef73aba --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java @@ -0,0 +1,208 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.cf; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.CalculatedFieldLinkId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.entity.AbstractEntityService; +import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; +import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; +import org.thingsboard.server.dao.exception.IncorrectParameterException; +import org.thingsboard.server.dao.service.DataValidator; + +import java.util.List; +import java.util.Optional; + +import static org.thingsboard.server.dao.service.Validator.validateId; +import static org.thingsboard.server.dao.service.Validator.validatePageLink; + +@Service("CalculatedFieldDaoService") +@Slf4j +@RequiredArgsConstructor +public class BaseCalculatedFieldService extends AbstractEntityService implements CalculatedFieldService { + + public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; + public static final String INCORRECT_CALCULATED_FIELD_ID = "Incorrect calculatedFieldId "; + public static final String INCORRECT_ENTITY_ID = "Incorrect entityId "; + + private final CalculatedFieldDao calculatedFieldDao; + private final CalculatedFieldLinkDao calculatedFieldLinkDao; + private final DataValidator calculatedFieldDataValidator; + private final DataValidator calculatedFieldLinkDataValidator; + + @Override + public CalculatedField save(CalculatedField calculatedField) { + CalculatedField oldCalculatedField = calculatedFieldDataValidator.validate(calculatedField, CalculatedField::getTenantId); + try { + TenantId tenantId = calculatedField.getTenantId(); + log.trace("Executing save calculated field, [{}]", calculatedField); + updateDebugSettings(tenantId, calculatedField, System.currentTimeMillis()); + CalculatedField savedCalculatedField = calculatedFieldDao.save(tenantId, calculatedField); + createOrUpdateCalculatedFieldLink(tenantId, savedCalculatedField); + eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(savedCalculatedField.getTenantId()).entityId(savedCalculatedField.getId()) + .entity(savedCalculatedField).oldEntity(oldCalculatedField).created(calculatedField.getId() == null).build()); + return savedCalculatedField; + } catch (Exception e) { + checkConstraintViolation(e, + "calculated_field_unq_key", "Calculated Field with such name is already in exists!", + "calculated_field_external_id_unq_key", "Calculated Field with such external id already exists!"); + throw e; + } + } + + @Override + public CalculatedField findById(TenantId tenantId, CalculatedFieldId calculatedFieldId) { + log.trace("Executing findById, tenantId [{}], calculatedFieldId [{}]", tenantId, calculatedFieldId); + validateId(tenantId, id -> INCORRECT_TENANT_ID + id); + validateId(calculatedFieldId, id -> INCORRECT_CALCULATED_FIELD_ID + id); + return calculatedFieldDao.findById(tenantId, calculatedFieldId.getId()); + } + + @Override + public List findCalculatedFieldIdsByEntityId(TenantId tenantId, EntityId entityId) { + log.trace("Executing findCalculatedFieldIdsByEntityId [{}]", entityId); + validateId(entityId.getId(), id -> INCORRECT_ENTITY_ID + id); + return calculatedFieldDao.findCalculatedFieldIdsByEntityId(tenantId, entityId); + } + + @Override + public List findCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId) { + log.trace("Executing findCalculatedFieldsByEntityId [{}]", entityId); + validateId(entityId.getId(), id -> INCORRECT_ENTITY_ID + id); + return calculatedFieldDao.findCalculatedFieldsByEntityId(tenantId, entityId); + } + + @Override + public PageData findAllCalculatedFields(PageLink pageLink) { + log.trace("Executing findAll, pageLink [{}]", pageLink); + validatePageLink(pageLink); + return calculatedFieldDao.findAll(pageLink); + } + + @Override + public PageData findAllCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId, PageLink pageLink) { + log.trace("Executing findAllByEntityId, entityId [{}], pageLink [{}]", entityId, pageLink); + validateId(entityId.getId(), id -> INCORRECT_ENTITY_ID + id); + validatePageLink(pageLink); + return calculatedFieldDao.findAllByEntityId(tenantId, entityId, pageLink); + } + + @Override + public void deleteCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId) { + validateId(tenantId, id -> INCORRECT_TENANT_ID + id); + validateId(calculatedFieldId, id -> INCORRECT_CALCULATED_FIELD_ID + id); + deleteEntity(tenantId, calculatedFieldId, false); + } + + @Override + public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { + CalculatedField calculatedField = calculatedFieldDao.findById(tenantId, id.getId()); + if (calculatedField == null) { + if (force) { + return; + } else { + throw new IncorrectParameterException("Unable to delete non-existent calculated field."); + } + } + deleteCalculatedField(tenantId, calculatedField); + } + + private void deleteCalculatedField(TenantId tenantId, CalculatedField calculatedField) { + log.trace("Executing deleteCalculatedField, tenantId [{}], calculatedFieldId [{}]", tenantId, calculatedField.getId()); + calculatedFieldDao.removeById(tenantId, calculatedField.getUuidId()); + eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entityId(calculatedField.getId()).entity(calculatedField).build()); + } + + @Override + public int deleteAllCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId) { + log.trace("Executing deleteAllCalculatedFieldsByEntityId, tenantId [{}], entityId [{}]", tenantId, entityId); + validateId(tenantId, id -> INCORRECT_TENANT_ID + id); + validateId(entityId.getId(), id -> INCORRECT_ENTITY_ID + id); + List calculatedFields = calculatedFieldDao.removeAllByEntityId(tenantId, entityId); + return calculatedFields.size(); + } + + @Override + public CalculatedFieldLink saveCalculatedFieldLink(TenantId tenantId, CalculatedFieldLink calculatedFieldLink) { + calculatedFieldLinkDataValidator.validate(calculatedFieldLink, CalculatedFieldLink::getTenantId); + log.trace("Executing save calculated field link, [{}]", calculatedFieldLink); + return calculatedFieldLinkDao.save(tenantId, calculatedFieldLink); + } + + @Override + public CalculatedFieldLink findCalculatedFieldLinkById(TenantId tenantId, CalculatedFieldLinkId calculatedFieldLinkId) { + log.trace("Executing findCalculatedFieldLinkById, tenantId [{}], calculatedFieldLinkId [{}]", tenantId, calculatedFieldLinkId); + validateId(tenantId, id -> INCORRECT_TENANT_ID + id); + validateId(calculatedFieldLinkId, id -> "Incorrect calculatedFieldLinkId " + id); + return calculatedFieldLinkDao.findById(tenantId, calculatedFieldLinkId.getId()); + } + + @Override + public List findAllCalculatedFieldLinksById(TenantId tenantId, CalculatedFieldId calculatedFieldId) { + log.trace("Executing findAllCalculatedFieldLinksById, calculatedFieldId [{}]", calculatedFieldId); + return calculatedFieldLinkDao.findCalculatedFieldLinksByCalculatedFieldId(tenantId, calculatedFieldId); + } + + @Override + public List findAllCalculatedFieldLinksByEntityId(TenantId tenantId, EntityId entityId) { + log.trace("Executing findAllCalculatedFieldLinksByEntityId, entityId [{}]", entityId); + return calculatedFieldLinkDao.findCalculatedFieldLinksByEntityId(tenantId, entityId); + } + + @Override + public PageData findAllCalculatedFieldLinks(PageLink pageLink) { + log.trace("Executing findAllCalculatedFieldLinks, pageLink [{}]", pageLink); + validatePageLink(pageLink); + return calculatedFieldLinkDao.findAll(pageLink); + } + + @Override + public boolean referencedInAnyCalculatedField(TenantId tenantId, EntityId referencedEntityId) { + return calculatedFieldDao.findAllByTenantId(tenantId).stream() + .filter(calculatedField -> !referencedEntityId.equals(calculatedField.getEntityId())) + .map(CalculatedField::getConfiguration) + .map(CalculatedFieldConfiguration::getReferencedEntities) + .anyMatch(referencedEntities -> referencedEntities.contains(referencedEntityId)); + } + + @Override + public Optional> findEntity(TenantId tenantId, EntityId entityId) { + return Optional.ofNullable(findById(tenantId, new CalculatedFieldId(entityId.getId()))); + } + + @Override + public EntityType getEntityType() { + return EntityType.CALCULATED_FIELD; + } + + private void createOrUpdateCalculatedFieldLink(TenantId tenantId, CalculatedField calculatedField) { + List links = calculatedField.getConfiguration().buildCalculatedFieldLinks(tenantId, calculatedField.getEntityId(), calculatedField.getId()); + links.forEach(link -> saveCalculatedFieldLink(tenantId, link)); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java b/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java new file mode 100644 index 0000000000..a966977968 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java @@ -0,0 +1,46 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.cf; + +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.Dao; + +import java.util.List; + +public interface CalculatedFieldDao extends Dao { + + List findAllByTenantId(TenantId tenantId); + + List findCalculatedFieldIdsByEntityId(TenantId tenantId, EntityId entityId); + + List findCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId); + + List findAll(); + + PageData findAll(PageLink pageLink); + + PageData findAllByEntityId(TenantId tenantId, EntityId entityId, PageLink pageLink); + + List removeAllByEntityId(TenantId tenantId, EntityId entityId); + + long countCFByEntityId(TenantId tenantId, EntityId entityId); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldLinkDao.java b/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldLinkDao.java new file mode 100644 index 0000000000..8b4a5e7086 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldLinkDao.java @@ -0,0 +1,38 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.cf; + +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.Dao; + +import java.util.List; + +public interface CalculatedFieldLinkDao extends Dao { + + List findCalculatedFieldLinksByCalculatedFieldId(TenantId tenantId, CalculatedFieldId calculatedFieldId); + + List findCalculatedFieldLinksByEntityId(TenantId tenantId, EntityId entityId); + + List findAll(); + + PageData findAll(PageLink pageLink); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/config/DedicatedEventsJpaDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/config/DedicatedEventsJpaDaoConfig.java index 5c24c45c16..258bac8dbd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/config/DedicatedEventsJpaDaoConfig.java +++ b/dao/src/main/java/org/thingsboard/server/dao/config/DedicatedEventsJpaDaoConfig.java @@ -29,6 +29,7 @@ import org.springframework.orm.jpa.JpaTransactionManager; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; import org.springframework.transaction.support.TransactionTemplate; import org.thingsboard.server.dao.model.sql.AuditLogEntity; +import org.thingsboard.server.dao.model.sql.CalculatedFieldDebugEventEntity; import org.thingsboard.server.dao.model.sql.ErrorEventEntity; import org.thingsboard.server.dao.model.sql.LifecycleEventEntity; import org.thingsboard.server.dao.model.sql.RuleChainDebugEventEntity; @@ -68,7 +69,7 @@ public class DedicatedEventsJpaDaoConfig { EntityManagerFactoryBuilder builder) { return builder .dataSource(eventsDataSource) - .packages(LifecycleEventEntity.class, StatisticsEventEntity.class, ErrorEventEntity.class, RuleNodeDebugEventEntity.class, RuleChainDebugEventEntity.class, AuditLogEntity.class) + .packages(LifecycleEventEntity.class, StatisticsEventEntity.class, ErrorEventEntity.class, RuleNodeDebugEventEntity.class, RuleChainDebugEventEntity.class, AuditLogEntity.class, CalculatedFieldDebugEventEntity.class) .persistenceUnit(EVENTS_PERSISTENCE_UNIT) .build(); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerDao.java b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerDao.java index b0a23bf6fc..c6bfa970a0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerDao.java @@ -30,7 +30,7 @@ import java.util.UUID; /** * The Interface CustomerDao. */ -public interface CustomerDao extends Dao, TenantEntityDao, ExportableEntityDao { +public interface CustomerDao extends Dao, TenantEntityDao, ExportableEntityDao { /** * Save or update customer object diff --git a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java index 76d05c2c98..4b05cdbc75 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java @@ -162,7 +162,7 @@ public class CustomerServiceImpl extends AbstractCachedEntityService, TenantEntityDao, ExportableEntityDao { +public interface DashboardDao extends Dao, TenantEntityDao, ExportableEntityDao { /** * Save or update dashboard object diff --git a/dao/src/main/java/org/thingsboard/server/dao/dashboard/DashboardServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/dashboard/DashboardServiceImpl.java index b8fff17a50..1b21310237 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/dashboard/DashboardServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/dashboard/DashboardServiceImpl.java @@ -172,7 +172,7 @@ public class DashboardServiceImpl extends AbstractEntityService implements Dashb var saved = dashboardDao.save(tenantId, dashboard); publishEvictEvent(new DashboardTitleEvictEvent(saved.getId())); eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId) - .entityId(saved.getId()).created(dashboard.getId() == null).build()); + .entityId(saved.getId()).entity(saved).created(dashboard.getId() == null).build()); if (dashboard.getId() == null) { countService.publishCountEntityEvictEvent(tenantId, EntityType.DASHBOARD); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceDao.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceDao.java index 099bf381fa..efc57119eb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceDao.java @@ -22,7 +22,10 @@ import org.thingsboard.server.common.data.DeviceInfo; import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.ProfileEntityIdInfo; +import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.ota.OtaPackageType; import org.thingsboard.server.common.data.page.PageData; @@ -39,7 +42,7 @@ import java.util.UUID; * The Interface DeviceDao. * */ -public interface DeviceDao extends Dao, TenantEntityDao, ExportableEntityDao { +public interface DeviceDao extends Dao, TenantEntityDao, ExportableEntityDao { /** * Find device info by id. @@ -85,6 +88,16 @@ public interface DeviceDao extends Dao, TenantEntityDao, ExportableEntit */ PageData findDevicesByTenantIdAndType(UUID tenantId, String type, PageLink pageLink); + /** + * Find device ids by tenantId, type and page link. + * + * @param tenantId the tenantId + * @param deviceProfileId the deviceProfileId + * @param pageLink the page link + * @return the list of device objects + */ + PageData findDeviceIdsByTenantIdAndDeviceProfileId(UUID tenantId, UUID deviceProfileId, PageLink pageLink); + PageData findDevicesByTenantIdAndTypeAndEmptyOtaPackage(UUID tenantId, UUID deviceProfileId, OtaPackageType type, @@ -218,5 +231,8 @@ public interface DeviceDao extends Dao, TenantEntityDao, ExportableEntit PageData findDeviceIdInfos(PageLink pageLink); + PageData findProfileEntityIdInfos(PageLink pageLink); + PageData findDeviceInfosByFilter(DeviceInfoFilter filter, PageLink pageLink); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java index 61523fd626..2ebcf046a6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java @@ -180,13 +180,12 @@ public class DeviceProfileServiceImpl extends CachedVersionedEntityService findProfileEntityIdInfos(PageLink pageLink) { + log.trace("Executing findProfileEntityIdInfos, pageLink [{}]", pageLink); + validatePageLink(pageLink); + return deviceDao.findProfileEntityIdInfos(pageLink); + } + @Override public PageData findDevicesByTenantIdAndType(TenantId tenantId, String type, PageLink pageLink) { log.trace("Executing findDevicesByTenantIdAndType, tenantId [{}], type [{}], pageLink [{}]", tenantId, type, pageLink); @@ -395,6 +403,15 @@ public class DeviceServiceImpl extends CachedVersionedEntityService findDeviceIdsByTenantIdAndDeviceProfileId(TenantId tenantId, DeviceProfileId deviceProfileId, PageLink pageLink) { + log.trace("Executing findDeviceIdsByTenantIdAndType, tenantId [{}], deviceProfileId [{}], pageLink [{}]", tenantId, deviceProfileId, pageLink); + validateId(tenantId, id -> INCORRECT_TENANT_ID + id); + validateId(deviceProfileId, id -> INCORRECT_DEVICE_PROFILE_ID + id); + validatePageLink(pageLink); + return deviceDao.findDeviceIdsByTenantIdAndDeviceProfileId(tenantId.getId(), deviceProfileId.getId(), pageLink); + } + @Override public PageData findDevicesByTenantIdAndTypeAndEmptyOtaPackage(TenantId tenantId, DeviceProfileId deviceProfileId, diff --git a/dao/src/main/java/org/thingsboard/server/dao/dictionary/KeyDictionaryDao.java b/dao/src/main/java/org/thingsboard/server/dao/dictionary/KeyDictionaryDao.java index 162cde1f2c..7b1c03bd6e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/dictionary/KeyDictionaryDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/dictionary/KeyDictionaryDao.java @@ -16,10 +16,15 @@ package org.thingsboard.server.dao.dictionary; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.model.sqlts.dictionary.KeyDictionaryEntry; + public interface KeyDictionaryDao { Integer getOrSaveKeyId(String strKey); String getKey(Integer keyId); + PageData findAll(PageLink pageLink); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeDao.java b/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeDao.java index c6d456c2d9..fdb9144ab2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeDao.java @@ -35,7 +35,7 @@ import java.util.UUID; * The Interface EdgeDao. * */ -public interface EdgeDao extends Dao, TenantEntityDao { +public interface EdgeDao extends Dao, TenantEntityDao { Edge save(TenantId tenantId, Edge edge); diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java b/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java index 75fc713be3..7560c7fb76 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java @@ -32,6 +32,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.dao.alarm.AlarmService; +import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.DataValidationException; @@ -66,6 +67,10 @@ public abstract class AbstractEntityService { @Autowired protected EntityViewService entityViewService; + @Lazy + @Autowired + protected CalculatedFieldService calculatedFieldService; + @Lazy @Autowired(required = false) protected EdgeService edgeService; diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java index f228db2825..a762aabf38 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java @@ -27,6 +27,8 @@ import org.thingsboard.server.common.data.HasLabel; import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.HasTitle; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.edqs.query.EdqsRequest; +import org.thingsboard.server.common.data.edqs.query.EdqsResponse; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; @@ -40,8 +42,10 @@ import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityFilterType; import org.thingsboard.server.common.data.query.EntityKey; import org.thingsboard.server.common.data.query.EntityListFilter; +import org.thingsboard.server.common.data.query.EntityTypeFilter; import org.thingsboard.server.common.data.query.KeyFilter; import org.thingsboard.server.common.data.query.RelationsQueryFilter; +import org.thingsboard.server.common.msg.edqs.EdqsApiService; import org.thingsboard.server.dao.exception.IncorrectParameterException; import java.util.ArrayList; @@ -50,10 +54,12 @@ import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ExecutionException; import java.util.function.Function; import java.util.stream.Collectors; import static org.thingsboard.server.common.data.id.EntityId.NULL_UUID; +import static org.thingsboard.server.common.data.query.EntityFilterType.ENTITY_TYPE; import static org.thingsboard.server.dao.service.Validator.validateEntityDataPageLink; import static org.thingsboard.server.dao.service.Validator.validateId; @@ -79,12 +85,24 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe @Lazy EntityServiceRegistry entityServiceRegistry; + @Autowired + @Lazy + private EdqsApiService edqsApiService; + @Override public long countEntitiesByQuery(TenantId tenantId, CustomerId customerId, EntityCountQuery query) { log.trace("Executing countEntitiesByQuery, tenantId [{}], customerId [{}], query [{}]", tenantId, customerId, query); validateId(tenantId, id -> INCORRECT_TENANT_ID + id); validateId(customerId, id -> INCORRECT_CUSTOMER_ID + id); validateEntityCountQuery(query); + + if (edqsApiService.isEnabled() && validForEdqs(query) && !tenantId.isSysTenantId()) { + EdqsRequest request = EdqsRequest.builder() + .entityCountQuery(query) + .build(); + EdqsResponse response = processEdqsRequest(tenantId, customerId, request); + return response.getEntityCountQueryResult(); + } return this.entityQueryDao.countEntitiesByQuery(tenantId, customerId, query); } @@ -95,6 +113,14 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe validateId(customerId, id -> INCORRECT_CUSTOMER_ID + id); validateEntityDataQuery(query); + if (edqsApiService.isEnabled() && validForEdqs(query)) { + EdqsRequest request = EdqsRequest.builder() + .entityDataQuery(query) + .build(); + EdqsResponse response = processEdqsRequest(tenantId, customerId, request); + return response.getEntityDataQueryResult(); + } + if (!isValidForOptimization(query)) { return this.entityQueryDao.findEntityDataByQuery(tenantId, customerId, query); } @@ -110,6 +136,25 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe return new PageData<>(result, entityDataByQuery.getTotalPages(), entityDataByQuery.getTotalElements(), entityDataByQuery.hasNext()); } + private boolean validForEdqs(EntityCountQuery query) { // for compatibility with PE + return true; + } + + private EdqsResponse processEdqsRequest(TenantId tenantId, CustomerId customerId, EdqsRequest request) { + EdqsResponse response; + try { + log.debug("[{}] Sending request to EDQS: {}", tenantId, request); + response = edqsApiService.processRequest(tenantId, customerId, request).get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + log.debug("[{}] Received response from EDQS: {}", tenantId, response); + if (response.getError() != null) { + throw new RuntimeException(response.getError()); + } + return response; + } + @Override public Optional fetchEntityName(TenantId tenantId, EntityId entityId) { log.trace("Executing fetchEntityName [{}]", entityId); @@ -134,6 +179,11 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe return fetchAndConvert(tenantId, entityId, this::getNameLabelAndCustomerDetails); } + @Override + public Optional> fetchEntity(TenantId tenantId, EntityId entityId) { + return fetchAndConvert(tenantId, entityId, Function.identity()); + } + private Optional fetchAndConvert(TenantId tenantId, EntityId entityId, Function, T> converter) { EntityDaoService entityDaoService = entityServiceRegistry.getServiceByEntityType(entityId.getEntityType()); Optional> entityOpt = entityDaoService.findEntity(tenantId, entityId); @@ -184,6 +234,8 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe throw new IncorrectParameterException("Query entity filter type must be specified."); } else if (query.getEntityFilter().getType().equals(EntityFilterType.RELATIONS_QUERY)) { validateRelationQuery((RelationsQueryFilter) query.getEntityFilter()); + } else if (query.getEntityFilter().getType().equals(ENTITY_TYPE)) { + validateEntityTypeQuery((EntityTypeFilter) query.getEntityFilter()); } } @@ -192,6 +244,12 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe validateEntityDataPageLink(query.getPageLink()); } + private static void validateEntityTypeQuery(EntityTypeFilter filter) { + if (filter.getEntityType() == null) { + throw new IncorrectParameterException("Entity type is required"); + } + } + private static void validateRelationQuery(RelationsQueryFilter queryFilter) { if (queryFilter.isMultiRoot() && queryFilter.getMultiRootEntitiesType() == null) { throw new IncorrectParameterException("Multi-root relation query filter should contain 'multiRootEntitiesType'"); diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/DefaultEntityServiceRegistry.java b/dao/src/main/java/org/thingsboard/server/dao/entity/DefaultEntityServiceRegistry.java index a795bddffa..9c50ad621f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/DefaultEntityServiceRegistry.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/DefaultEntityServiceRegistry.java @@ -43,6 +43,9 @@ public class DefaultEntityServiceRegistry implements EntityServiceRegistry { if (EntityType.RULE_CHAIN.equals(entityType)) { entityDaoServicesMap.put(EntityType.RULE_NODE, entityDaoService); } + if (EntityType.CALCULATED_FIELD.equals(entityType)) { + entityDaoServicesMap.put(EntityType.CALCULATED_FIELD_LINK, entityDaoService); + } }); log.debug("Initialized EntityServiceRegistry total [{}] entries", entityDaoServicesMap.size()); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/EntityDaoRegistry.java b/dao/src/main/java/org/thingsboard/server/dao/entity/EntityDaoRegistry.java index 74deb29247..2dbda352b4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/EntityDaoRegistry.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/EntityDaoRegistry.java @@ -26,6 +26,7 @@ import java.util.Map; @Service @Slf4j +@SuppressWarnings({"unchecked"}) public class EntityDaoRegistry { private final Map> daos = new EnumMap<>(EntityType.class); @@ -39,7 +40,6 @@ public class EntityDaoRegistry { }); } - @SuppressWarnings("unchecked") public Dao getDao(EntityType entityType) { Dao dao = (Dao) daos.get(entityType); if (dao == null) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 867264cfaf..0e742e20db 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -123,7 +123,7 @@ public class EntityViewServiceImpl extends CachedVersionedEntityService { private final T oldEntity; private final EntityId entityId; private final Boolean created; + private final Boolean broadcastEvent; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java b/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java index 120287fb83..1ca2973936 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java @@ -75,6 +75,7 @@ public class CleanUpService { submitTask(HousekeeperTask.deleteTelemetry(tenantId, entityId)); submitTask(HousekeeperTask.deleteEvents(tenantId, entityId)); submitTask(HousekeeperTask.deleteAlarms(tenantId, entityId)); + submitTask(HousekeeperTask.deleteCalculatedFields(tenantId, entityId)); } public void removeTenantEntities(TenantId tenantId, EntityType... entityTypes) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index aaf9659f1b..148908d063 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -376,6 +376,7 @@ public class ModelConstants { public static final String STATS_EVENT_TABLE_NAME = "stats_event"; public static final String RULE_NODE_DEBUG_EVENT_TABLE_NAME = "rule_node_debug_event"; public static final String RULE_CHAIN_DEBUG_EVENT_TABLE_NAME = "rule_chain_debug_event"; + public static final String CALCULATED_FIELD_DEBUG_EVENT_TABLE_NAME = "cf_debug_event"; public static final String EVENT_TENANT_ID_PROPERTY = TENANT_ID_PROPERTY; public static final String EVENT_SERVICE_ID_PROPERTY = "service_id"; @@ -400,6 +401,10 @@ public class ModelConstants { public static final String EVENT_METADATA_COLUMN_NAME = "e_metadata"; public static final String EVENT_MESSAGE_COLUMN_NAME = "e_message"; + public static final String EVENT_CALCULATED_FIELD_ID_COLUMN_NAME = "cf_id"; + public static final String EVENT_CALCULATED_FIELD_ARGUMENTS_COLUMN_NAME = "e_args"; + public static final String EVENT_CALCULATED_FIELD_RESULT_COLUMN_NAME = "e_result"; + public static final String DEBUG_MODE = "debug_mode"; public static final String DEBUG_SETTINGS = "debug_settings"; public static final String SINGLETON_MODE = "singleton_mode"; @@ -712,6 +717,28 @@ public class ModelConstants { public static final String QR_CODE_SETTINGS_BUNDLE_ID_PROPERTY = "mobile_app_bundle_id"; public static final String QR_CODE_SETTINGS_CONFIG_PROPERTY = "qr_code_config"; + /** + * Calculated fields constants. + */ + public static final String CALCULATED_FIELD_TABLE_NAME = "calculated_field"; + public static final String CALCULATED_FIELD_TENANT_ID_COLUMN = TENANT_ID_COLUMN; + public static final String CALCULATED_FIELD_ENTITY_TYPE = ENTITY_TYPE_COLUMN; + public static final String CALCULATED_FIELD_ENTITY_ID = ENTITY_ID_COLUMN; + public static final String CALCULATED_FIELD_TYPE = "type"; + public static final String CALCULATED_FIELD_NAME = "name"; + public static final String CALCULATED_FIELD_CONFIGURATION_VERSION = "configuration_version"; + public static final String CALCULATED_FIELD_CONFIGURATION = "configuration"; + public static final String CALCULATED_FIELD_VERSION = "version"; + + /** + * Calculated field links constants. + */ + public static final String CALCULATED_FIELD_LINK_TABLE_NAME = "calculated_field_link"; + public static final String CALCULATED_FIELD_LINK_TENANT_ID_COLUMN = TENANT_ID_COLUMN; + public static final String CALCULATED_FIELD_LINK_ENTITY_TYPE = ENTITY_TYPE_COLUMN; + public static final String CALCULATED_FIELD_LINK_ENTITY_ID = ENTITY_ID_COLUMN; + public static final String CALCULATED_FIELD_LINK_CALCULATED_FIELD_ID = "calculated_field_id"; + protected static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN, JSON_VALUE_COLUMN, KEY_COLUMN, TS_COLUMN}; protected static final String[] COUNT_AGGREGATION_COLUMNS = new String[]{count(LONG_VALUE_COLUMN), count(DOUBLE_VALUE_COLUMN), count(BOOLEAN_VALUE_COLUMN), count(STRING_VALUE_COLUMN), count(JSON_VALUE_COLUMN), max(TS_COLUMN)}; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldDebugEventEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldDebugEventEntity.java new file mode 100644 index 0000000000..cf771238b9 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldDebugEventEntity.java @@ -0,0 +1,104 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.model.sql; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.event.CalculatedFieldDebugEvent; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.model.BaseEntity; + +import java.util.UUID; + +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_DEBUG_EVENT_TABLE_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.EVENT_CALCULATED_FIELD_ARGUMENTS_COLUMN_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.EVENT_CALCULATED_FIELD_ID_COLUMN_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.EVENT_CALCULATED_FIELD_RESULT_COLUMN_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.EVENT_ENTITY_ID_COLUMN_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.EVENT_ENTITY_TYPE_COLUMN_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.EVENT_ERROR_COLUMN_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.EVENT_MSG_ID_COLUMN_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.EVENT_MSG_TYPE_COLUMN_NAME; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = CALCULATED_FIELD_DEBUG_EVENT_TABLE_NAME) +@NoArgsConstructor +public class CalculatedFieldDebugEventEntity extends EventEntity implements BaseEntity { + + @Column(name = EVENT_CALCULATED_FIELD_ID_COLUMN_NAME) + private UUID calculatedFieldId; + @Column(name = EVENT_ENTITY_ID_COLUMN_NAME) + private UUID eventEntityId; + @Column(name = EVENT_ENTITY_TYPE_COLUMN_NAME) + private String eventEntityType; + @Column(name = EVENT_MSG_ID_COLUMN_NAME) + private UUID msgId; + @Column(name = EVENT_MSG_TYPE_COLUMN_NAME) + private String msgType; + @Column(name = EVENT_CALCULATED_FIELD_ARGUMENTS_COLUMN_NAME) + private String arguments; + @Column(name = EVENT_CALCULATED_FIELD_RESULT_COLUMN_NAME) + private String result; + @Column(name = EVENT_ERROR_COLUMN_NAME) + private String error; + + public CalculatedFieldDebugEventEntity(CalculatedFieldDebugEvent event) { + super(event); + if (event.getCalculatedFieldId() != null) { + this.calculatedFieldId = event.getCalculatedFieldId().getId(); + } + if (event.getEventEntity() != null) { + this.eventEntityId = event.getEventEntity().getId(); + this.eventEntityType = event.getEventEntity().getEntityType().name(); + } + this.msgId = event.getMsgId(); + this.msgType = event.getMsgType(); + this.arguments = event.getArguments(); + this.result = event.getResult(); + this.error = event.getError(); + } + + @Override + public CalculatedFieldDebugEvent toData() { + var builder = CalculatedFieldDebugEvent.builder() + .id(id) + .tenantId(TenantId.fromUUID(tenantId)) + .ts(ts) + .serviceId(serviceId) + .entityId(entityId) + .msgId(msgId) + .msgType(msgType) + .arguments(arguments) + .result(result) + .error(error); + if (calculatedFieldId != null) { + builder.calculatedFieldId(new CalculatedFieldId(calculatedFieldId)); + } + if (eventEntityId != null) { + builder.eventEntity(EntityIdFactory.getByTypeAndUuid(eventEntityType, eventEntityId)); + } + return builder.build(); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java new file mode 100644 index 0000000000..de6a1365b1 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java @@ -0,0 +1,117 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.model.sql; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.debug.DebugSettings; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.model.BaseEntity; +import org.thingsboard.server.dao.model.BaseVersionedEntity; +import org.thingsboard.server.dao.util.mapping.JsonConverter; + +import java.util.UUID; + +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_CONFIGURATION; +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_CONFIGURATION_VERSION; +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_ENTITY_ID; +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_ENTITY_TYPE; +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_TABLE_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_TENANT_ID_COLUMN; +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_TYPE; +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_VERSION; +import static org.thingsboard.server.dao.model.ModelConstants.DEBUG_SETTINGS; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = CALCULATED_FIELD_TABLE_NAME) +public class CalculatedFieldEntity extends BaseVersionedEntity implements BaseEntity { + + @Column(name = CALCULATED_FIELD_TENANT_ID_COLUMN) + private UUID tenantId; + + @Column(name = CALCULATED_FIELD_ENTITY_TYPE) + private String entityType; + + @Column(name = CALCULATED_FIELD_ENTITY_ID) + private UUID entityId; + + @Column(name = CALCULATED_FIELD_TYPE) + private String type; + + @Column(name = CALCULATED_FIELD_NAME) + private String name; + + @Column(name = CALCULATED_FIELD_CONFIGURATION_VERSION) + private int configurationVersion; + + @Convert(converter = JsonConverter.class) + @Column(name = CALCULATED_FIELD_CONFIGURATION) + private JsonNode configuration; + + @Column(name = CALCULATED_FIELD_VERSION) + private Long version; + + @Column(name = DEBUG_SETTINGS) + private String debugSettings; + + public CalculatedFieldEntity() { + super(); + } + + public CalculatedFieldEntity(CalculatedField calculatedField) { + this.setUuid(calculatedField.getUuidId()); + this.createdTime = calculatedField.getCreatedTime(); + this.tenantId = calculatedField.getTenantId().getId(); + this.entityType = calculatedField.getEntityId().getEntityType().name(); + this.entityId = calculatedField.getEntityId().getId(); + this.type = calculatedField.getType().name(); + this.name = calculatedField.getName(); + this.configurationVersion = calculatedField.getConfigurationVersion(); + this.configuration = JacksonUtil.valueToTree(calculatedField.getConfiguration()); + this.version = calculatedField.getVersion(); + this.debugSettings = JacksonUtil.toString(calculatedField.getDebugSettings()); + } + + @Override + public CalculatedField toData() { + CalculatedField calculatedField = new CalculatedField(new CalculatedFieldId(id)); + calculatedField.setCreatedTime(createdTime); + calculatedField.setTenantId(TenantId.fromUUID(tenantId)); + calculatedField.setEntityId(EntityIdFactory.getByTypeAndUuid(entityType, entityId)); + calculatedField.setType(CalculatedFieldType.valueOf(type)); + calculatedField.setName(name); + calculatedField.setConfigurationVersion(configurationVersion); + calculatedField.setConfiguration(JacksonUtil.treeToValue(configuration, CalculatedFieldConfiguration.class)); + calculatedField.setVersion(version); + calculatedField.setDebugSettings(JacksonUtil.fromString(debugSettings, DebugSettings.class)); + return calculatedField; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldLinkEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldLinkEntity.java new file mode 100644 index 0000000000..0f2a6455ec --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldLinkEntity.java @@ -0,0 +1,79 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.model.sql; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.CalculatedFieldLinkId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.model.BaseEntity; +import org.thingsboard.server.dao.model.BaseSqlEntity; + +import java.util.UUID; + +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_LINK_CALCULATED_FIELD_ID; +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_LINK_ENTITY_ID; +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_LINK_ENTITY_TYPE; +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_LINK_TABLE_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_LINK_TENANT_ID_COLUMN; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = CALCULATED_FIELD_LINK_TABLE_NAME) +public class CalculatedFieldLinkEntity extends BaseSqlEntity implements BaseEntity { + + @Column(name = CALCULATED_FIELD_LINK_TENANT_ID_COLUMN) + private UUID tenantId; + + @Column(name = CALCULATED_FIELD_LINK_ENTITY_TYPE) + private String entityType; + + @Column(name = CALCULATED_FIELD_LINK_ENTITY_ID) + private UUID entityId; + + @Column(name = CALCULATED_FIELD_LINK_CALCULATED_FIELD_ID) + private UUID calculatedFieldId; + + public CalculatedFieldLinkEntity() { + super(); + } + + public CalculatedFieldLinkEntity(CalculatedFieldLink calculatedFieldLink) { + super(calculatedFieldLink); + this.tenantId = calculatedFieldLink.getTenantId().getId(); + this.entityType = calculatedFieldLink.getEntityId().getEntityType().name(); + this.entityId = calculatedFieldLink.getEntityId().getId(); + this.calculatedFieldId = calculatedFieldLink.getCalculatedFieldId().getId(); + } + + @Override + public CalculatedFieldLink toData() { + CalculatedFieldLink calculatedFieldLink = new CalculatedFieldLink(new CalculatedFieldLinkId(id)); + calculatedFieldLink.setCreatedTime(createdTime); + calculatedFieldLink.setTenantId(TenantId.fromUUID(tenantId)); + calculatedFieldLink.setEntityId(EntityIdFactory.getByTypeAndUuid(entityType, entityId)); + calculatedFieldLink.setCalculatedFieldId(new CalculatedFieldId(calculatedFieldId)); + return calculatedFieldLink; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java index 782872b12f..b3870a9e3a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java @@ -26,6 +26,7 @@ import jakarta.persistence.SqlResultSetMapping; import jakarta.persistence.SqlResultSetMappings; import jakarta.persistence.Table; import lombok.Data; +import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.dao.model.sql.AbstractTsKvEntity; import org.thingsboard.server.dao.sqlts.latest.SearchTsKvLatestRepository; @@ -91,4 +92,12 @@ public final class TsKvLatestEntity extends AbstractTsKvEntity { this.strKey = strKey; this.version = version; } + + @Override + public TsKvEntry toData() { + TsKvEntry tsKvEntry = super.toData(); + tsKvEntry.setVersion(version); + return tsKvEntry; + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/NotificationTargetDao.java b/dao/src/main/java/org/thingsboard/server/dao/notification/NotificationTargetDao.java index 7505d314d4..eeae618c61 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/NotificationTargetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/NotificationTargetDao.java @@ -28,7 +28,7 @@ import org.thingsboard.server.dao.TenantEntityDao; import java.util.List; -public interface NotificationTargetDao extends Dao, TenantEntityDao, ExportableEntityDao { +public interface NotificationTargetDao extends Dao, TenantEntityDao, ExportableEntityDao { PageData findByTenantIdAndPageLink(TenantId tenantId, PageLink pageLink); diff --git a/dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java b/dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java index bc072567d6..f8f877e55e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java @@ -21,5 +21,7 @@ import org.thingsboard.server.dao.Dao; import org.thingsboard.server.dao.TenantEntityWithDataDao; public interface OtaPackageDao extends Dao, TenantEntityWithDataDao { + Long sumDataSizeByTenantId(TenantId tenantId); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/queue/BaseQueueStatsService.java b/dao/src/main/java/org/thingsboard/server/dao/queue/BaseQueueStatsService.java index 87c2b513a2..9e4c7136d5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/queue/BaseQueueStatsService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/queue/BaseQueueStatsService.java @@ -27,6 +27,8 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.queue.QueueStats; import org.thingsboard.server.dao.entity.AbstractEntityService; +import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; +import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.Validator; @@ -51,7 +53,10 @@ public class BaseQueueStatsService extends AbstractEntityService implements Queu public QueueStats save(TenantId tenantId, QueueStats queueStats) { log.trace("Executing save [{}]", queueStats); queueStatsValidator.validate(queueStats, QueueStats::getTenantId); - return queueStatsDao.save(tenantId, queueStats); + QueueStats savedQueueStats = queueStatsDao.save(tenantId, queueStats); + eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(savedQueueStats.getTenantId()).entityId(savedQueueStats.getId()) + .entity(savedQueueStats).created(queueStats.getId() == null).build()); + return savedQueueStats; } @Override @@ -80,7 +85,7 @@ public class BaseQueueStatsService extends AbstractEntityService implements Queu public PageData findByTenantId(TenantId tenantId, PageLink pageLink) { log.trace("Executing findByTenantId, tenantId: [{}]", tenantId); Validator.validatePageLink(pageLink); - return queueStatsDao.findByTenantId(tenantId, pageLink); + return queueStatsDao.findAllByTenantId(tenantId, pageLink); } @Override @@ -93,6 +98,7 @@ public class BaseQueueStatsService extends AbstractEntityService implements Queu @Override public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { queueStatsDao.removeById(tenantId, id.getId()); + eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entityId(id).build()); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/queue/QueueStatsDao.java b/dao/src/main/java/org/thingsboard/server/dao/queue/QueueStatsDao.java index c9b6df0016..cd1f0701fe 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/queue/QueueStatsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/queue/QueueStatsDao.java @@ -17,19 +17,16 @@ package org.thingsboard.server.dao.queue; import org.thingsboard.server.common.data.id.QueueStatsId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.queue.QueueStats; import org.thingsboard.server.dao.Dao; +import org.thingsboard.server.dao.TenantEntityDao; import java.util.List; -public interface QueueStatsDao extends Dao { +public interface QueueStatsDao extends Dao, TenantEntityDao { QueueStats findByTenantIdQueueNameAndServiceId(TenantId tenantId, String queueName, String serviceId); - PageData findByTenantId(TenantId tenantId, PageLink pageLink); - void deleteByTenantId(TenantId tenantId); List findByIds(TenantId tenantId, List queueStatsIds); diff --git a/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java b/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java index a0e75e062c..87f7e44a1f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java @@ -236,6 +236,7 @@ public class BaseRelationService implements RelationService { return Futures.transform(future, deletedEvent -> { if (deletedEvent != null) { handleEvictEvent(EntityRelationEvent.from(deletedEvent)); + eventPublisher.publishEvent(new RelationActionEvent(tenantId, deletedEvent, ActionType.RELATION_DELETED)); } return deletedEvent != null; }, MoreExecutors.directExecutor()); @@ -267,6 +268,7 @@ public class BaseRelationService implements RelationService { for (EntityRelation relation : inboundRelations) { eventPublisher.publishEvent(EntityRelationEvent.from(relation)); + eventPublisher.publishEvent(new RelationActionEvent(tenantId, relation, ActionType.RELATION_DELETED)); } List outboundRelations; @@ -278,6 +280,7 @@ public class BaseRelationService implements RelationService { for (EntityRelation relation : outboundRelations) { eventPublisher.publishEvent(EntityRelationEvent.from(relation)); + eventPublisher.publishEvent(new RelationActionEvent(tenantId, relation, ActionType.RELATION_DELETED)); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java index e91c1939f6..3be7f5b91c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java @@ -379,6 +379,7 @@ public class BaseImageService extends BaseResourceService implements ImageServic JsonNode defaultConfig = widgetTypeDetails.getDefaultConfig(); if (defaultConfig != null) { updated |= convertToImageUrlsByMapping(tenantId, WIDGET_TYPE_BASE64_MAPPING, Collections.singletonMap("prefix", prefix), defaultConfig, imagesLinks); + updated |= convertToImageUrls(tenantId, prefix, defaultConfig, imagesLinks); widgetTypeDetails.setDefaultConfig(defaultConfig); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java b/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java index 3fb80332b5..bea9e6e7e9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java @@ -118,16 +118,24 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC @Override @Transactional public RuleChain saveRuleChain(RuleChain ruleChain, boolean publishSaveEvent) { - ruleChainValidator.validate(ruleChain, RuleChain::getTenantId); + return saveRuleChain(ruleChain, publishSaveEvent, true); + } + + @Override + @Transactional + public RuleChain saveRuleChain(RuleChain ruleChain, boolean publishSaveEvent, boolean doValidate) { + log.trace("Executing doSaveRuleChain [{}]", ruleChain); + if (doValidate) { + ruleChainValidator.validate(ruleChain, RuleChain::getTenantId); + } try { RuleChain savedRuleChain = ruleChainDao.saveAndFlush(ruleChain.getTenantId(), ruleChain); if (ruleChain.getId() == null) { entityCountService.publishCountEntityEvictEvent(ruleChain.getTenantId(), EntityType.RULE_CHAIN); } - if (publishSaveEvent) { - eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(savedRuleChain.getTenantId()) - .entity(savedRuleChain).entityId(savedRuleChain.getId()).created(ruleChain.getId() == null).build()); - } + eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(savedRuleChain.getTenantId()) + .entity(savedRuleChain).entityId(savedRuleChain.getId()).created(ruleChain.getId() == null) + .broadcastEvent(publishSaveEvent).build()); return savedRuleChain; } catch (Exception e) { checkConstraintViolation(e, "rule_chain_external_id_unq_key", "Rule Chain with such external id already exists!"); @@ -289,9 +297,8 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC relationService.saveRelations(tenantId, relations); } ruleChain = ruleChainDao.save(tenantId, ruleChain); - if (publishSaveEvent) { - eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entity(ruleChain).entityId(ruleChain.getId()).build()); - } + eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entity(ruleChain) + .entityId(ruleChain.getId()).broadcastEvent(publishSaveEvent).build()); return RuleChainUpdateResult.successful(updatedRuleNodes); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java b/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java index f5bc8a9c56..5b09eec42a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java @@ -31,7 +31,7 @@ import java.util.UUID; /** * Created by igor on 3/12/18. */ -public interface RuleChainDao extends Dao, TenantEntityDao, ExportableEntityDao { +public interface RuleChainDao extends Dao, TenantEntityDao, ExportableEntityDao { /** * Find rule chains by tenantId and page link. diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java index ba88d446d9..9f1f583108 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java @@ -21,6 +21,7 @@ import org.apache.commons.io.FileUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.EntityId; @@ -158,6 +159,9 @@ public abstract class DataValidator> { protected static void validateQueueName(String name) { validateQueueNameOrTopic(name, NAME); + if (DataConstants.CF_QUEUE_NAME.equals(name) || DataConstants.CF_STATES_QUEUE_NAME.equals(name)) { + throw new DataValidationException(String.format("The queue name '%s' is not allowed. This name is reserved for internal use. Please choose a different name.", name)); + } } protected static void validateQueueTopic(String topic) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java new file mode 100644 index 0000000000..187de20667 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java @@ -0,0 +1,74 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.service.validator; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.dao.cf.CalculatedFieldDao; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.dao.usagerecord.ApiLimitService; + +@Component +public class CalculatedFieldDataValidator extends DataValidator { + + @Autowired + private CalculatedFieldDao calculatedFieldDao; + + @Autowired + private ApiLimitService apiLimitService; + + @Override + protected void validateCreate(TenantId tenantId, CalculatedField calculatedField) { + validateNumberOfCFsPerEntity(tenantId, calculatedField.getEntityId()); + validateNumberOfArgumentsPerCF(tenantId, calculatedField); + } + + @Override + protected CalculatedField validateUpdate(TenantId tenantId, CalculatedField calculatedField) { + CalculatedField old = calculatedFieldDao.findById(calculatedField.getTenantId(), calculatedField.getId().getId()); + if (old == null) { + throw new DataValidationException("Can't update non existing calculated field!"); + } + validateNumberOfArgumentsPerCF(tenantId, calculatedField); + return old; + } + + private void validateNumberOfCFsPerEntity(TenantId tenantId, EntityId entityId) { + long maxCFsPerEntity = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxCalculatedFieldsPerEntity); + if (maxCFsPerEntity <= 0) { + return; + } + if (calculatedFieldDao.countCFByEntityId(tenantId, entityId) >= maxCFsPerEntity) { + throw new DataValidationException("Calculated fields per entity limit reached!"); + } + } + + private void validateNumberOfArgumentsPerCF(TenantId tenantId, CalculatedField calculatedField) { + long maxArgumentsPerCF = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxArgumentsPerCF); + if (maxArgumentsPerCF <= 0) { + return; + } + if (calculatedField.getConfiguration().getArguments().size() > maxArgumentsPerCF) { + throw new DataValidationException("Calculated field arguments limit reached!"); + } + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldLinkDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldLinkDataValidator.java new file mode 100644 index 0000000000..aaba200c92 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldLinkDataValidator.java @@ -0,0 +1,41 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.service.validator; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.cf.CalculatedFieldLinkDao; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.service.DataValidator; + +@Component +public class CalculatedFieldLinkDataValidator extends DataValidator { + + @Autowired + private CalculatedFieldLinkDao calculatedFieldLinkDao; + + @Override + protected CalculatedFieldLink validateUpdate(TenantId tenantId, CalculatedFieldLink calculatedFieldLink) { + CalculatedFieldLink old = calculatedFieldLinkDao.findById(calculatedFieldLink.getTenantId(), calculatedFieldLink.getId().getId()); + if (old == null) { + throw new DataValidationException("Can't update non existing calculated field link!"); + } + return old; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmCommentRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmCommentRepository.java index 93e2b0f2c3..1a24ff6abb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmCommentRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmCommentRepository.java @@ -35,5 +35,9 @@ public interface AlarmCommentRepository extends JpaRepository findAllByAlarmId(@Param("alarmId") UUID alarmId, - Pageable pageable); + Pageable pageable); + + @Query("SELECT c FROM AlarmCommentEntity c WHERE c.userId IN (SELECT u.id FROM UserEntity u WHERE u.tenantId = :tenantId)") + Page findByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java index 9cfa7a63eb..b9c1abe416 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java @@ -414,4 +414,6 @@ public interface AlarmRepository extends JpaRepository { @Param("alarmSeverities") List alarmSeverities, int limit); + Page findByTenantId(UUID tenantId, Pageable pageable); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/EntityAlarmRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/EntityAlarmRepository.java index fa97fe258e..51eeb1c21d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/EntityAlarmRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/EntityAlarmRepository.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.dao.sql.alarm; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -42,4 +44,6 @@ public interface EntityAlarmRepository extends JpaRepository findAllByEntityId(UUID entityId); + Page findByTenantId(UUID tenantId, Pageable pageable); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmCommentDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmCommentDao.java index 0fb7220784..42c9d68b01 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmCommentDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmCommentDao.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; import org.thingsboard.server.dao.alarm.AlarmCommentDao; import org.thingsboard.server.dao.model.sql.AlarmCommentEntity; import org.thingsboard.server.dao.sql.JpaPartitionedAbstractDao; @@ -44,7 +45,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.ALARM_COMMENT_TABL @Component @SqlDao @RequiredArgsConstructor -public class JpaAlarmCommentDao extends JpaPartitionedAbstractDao implements AlarmCommentDao { +public class JpaAlarmCommentDao extends JpaPartitionedAbstractDao implements AlarmCommentDao, TenantEntityDao { private final SqlPartitioningRepository partitioningRepository; @Value("${sql.alarm_comments.partition_size:168}") private int partitionSizeInHours; @@ -76,6 +77,11 @@ public class JpaAlarmCommentDao extends JpaPartitionedAbstractDao findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return DaoUtil.toPageData(alarmCommentRepository.findByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink))); + } + @Override protected Class getEntityClass() { return AlarmCommentEntity.class; @@ -85,4 +91,5 @@ public class JpaAlarmCommentDao extends JpaPartitionedAbstractDao getRepository() { return alarmCommentRepository; } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmDao.java index 1672ab4b3a..c21d8ae928 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmDao.java @@ -57,6 +57,7 @@ import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.OriginatorAlarmFilter; import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; import org.thingsboard.server.dao.alarm.AlarmDao; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.model.sql.AlarmEntity; @@ -84,7 +85,7 @@ import static org.thingsboard.server.dao.DaoUtil.toPageable; @Slf4j @Component @SqlDao -public class JpaAlarmDao extends JpaAbstractDao implements AlarmDao { +public class JpaAlarmDao extends JpaAbstractDao implements AlarmDao, TenantEntityDao { @Autowired private AlarmRepository alarmRepository; @@ -415,8 +416,8 @@ public class JpaAlarmDao extends JpaAbstractDao implements A } @Override - public long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query) { - return alarmQueryRepository.countAlarmsByQuery(tenantId, customerId, query); + public long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query, Collection orderedEntityIds) { + return alarmQueryRepository.countAlarmsByQuery(tenantId, customerId, query, orderedEntityIds); } @Override @@ -551,6 +552,11 @@ public class JpaAlarmDao extends JpaAbstractDao implements A } } + @Override + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return DaoUtil.toPageData(alarmRepository.findByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink))); + } + @Override public EntityType getEntityType() { return EntityType.ALARM; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaEntityAlarmDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaEntityAlarmDao.java new file mode 100644 index 0000000000..796fe63da8 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaEntityAlarmDao.java @@ -0,0 +1,40 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.sql.alarm; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.alarm.EntityAlarm; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; +import org.thingsboard.server.dao.util.SqlDao; + +@Component +@SqlDao +public class JpaEntityAlarmDao implements TenantEntityDao { + + @Autowired + private EntityAlarmRepository entityAlarmRepository; + + @Override + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return DaoUtil.toPageData(entityAlarmRepository.findByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink, "entityId", "alarmId"))); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetProfileRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetProfileRepository.java index fbcfbbda8a..eb35a4e18e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetProfileRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetProfileRepository.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.sql.asset; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -22,6 +23,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.asset.AssetProfileInfo; +import org.thingsboard.server.common.data.edqs.fields.AssetProfileFields; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.AssetProfileEntity; @@ -81,4 +83,8 @@ public interface AssetProfileRepository extends JpaRepository findAllTenantAssetProfileNames(@Param("tenantId") UUID tenantId); + @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.AssetProfileFields(a.id, a.createdTime, a.tenantId," + + "a.name, a.version, a.isDefault) FROM AssetProfileEntity a WHERE a.id > :id ORDER BY a.id") + List findNextBatch(@Param("id") UUID id, Limit limit); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java index 2cbdb0b90b..e475864684 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.dao.sql.asset; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.edqs.fields.AssetFields; import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.AssetEntity; @@ -139,6 +141,15 @@ public interface AssetRepository extends JpaRepository, Expor @Param("textSearch") String textSearch, Pageable pageable); + @Query("SELECT a.id FROM AssetEntity a " + + "WHERE a.tenantId = :tenantId " + + "AND a.assetProfileId = :assetProfileId " + + "AND (:textSearch IS NULL OR ilike(a.type, CONCAT('%', :textSearch, '%')) = true) ") + Page findAssetIdsByTenantIdAndAssetProfileId(@Param("tenantId") UUID tenantId, + @Param("assetProfileId") UUID assetProfileId, + @Param("textSearch") String textSearch, + Pageable pageable); + @Query("SELECT a FROM AssetEntity a WHERE a.tenantId = :tenantId " + "AND a.customerId = :customerId AND a.type = :type " + @@ -216,4 +227,9 @@ public interface AssetRepository extends JpaRepository, Expor @Query(value = "SELECT DISTINCT new org.thingsboard.server.common.data.util.TbPair(a.tenantId , a.type) FROM AssetEntity a") Page> getAllAssetTypes(Pageable pageable); + + @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.AssetFields(a.id, a.createdTime, a.tenantId, a.customerId," + + "a.name, a.version, a.type, a.label, a.assetProfileId, a.additionalInfo) FROM AssetEntity a WHERE a.id > :id ORDER BY a.id") + List findAllFields(@Param("id") UUID id, Limit limit); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java index 5783ad0c05..c61d894de5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java @@ -18,12 +18,15 @@ package org.thingsboard.server.dao.sql.asset; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetInfo; +import org.thingsboard.server.common.data.edqs.fields.AssetFields; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -35,12 +38,15 @@ import org.thingsboard.server.dao.asset.AssetDao; import org.thingsboard.server.dao.model.sql.AssetEntity; import org.thingsboard.server.dao.model.sql.AssetInfoEntity; import org.thingsboard.server.dao.sql.JpaAbstractDao; +import org.thingsboard.server.dao.sql.device.NativeAssetRepository; +import org.thingsboard.server.dao.sql.device.NativeDeviceRepository; import org.thingsboard.server.dao.util.SqlDao; import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.stream.Collectors; import static org.thingsboard.server.dao.DaoUtil.convertTenantEntityInfosToDto; @@ -55,6 +61,9 @@ public class JpaAssetDao extends JpaAbstractDao implements A @Autowired private AssetRepository assetRepository; + @Autowired + private NativeAssetRepository nativeAssetRepository; + @Autowired private AssetProfileRepository assetProfileRepository; @@ -159,6 +168,16 @@ public class JpaAssetDao extends JpaAbstractDao implements A DaoUtil.toPageable(pageLink, AssetInfoEntity.assetInfoColumnMap))); } + @Override + public PageData findAssetIdsByTenantIdAndAssetProfileId(UUID tenantId, UUID assetProfileId, PageLink pageLink) { + return DaoUtil.pageToPageData(assetRepository.findAssetIdsByTenantIdAndAssetProfileId( + tenantId, + assetProfileId, + pageLink.getTextSearch(), + DaoUtil.toPageable(pageLink))) + .mapData(AssetId::new); + } + @Override public PageData findAssetsByTenantIdAndCustomerIdAndType(UUID tenantId, UUID customerId, String type, PageLink pageLink) { return DaoUtil.toPageData(assetRepository @@ -241,6 +260,12 @@ public class JpaAssetDao extends JpaAbstractDao implements A DaoUtil.toPageable(pageLink, Arrays.asList(new SortOrder("tenantId"), new SortOrder("type"))))); } + @Override + public PageData findProfileEntityIdInfos(PageLink pageLink) { + log.debug("Find profile device id infos by pageLink [{}]", pageLink); + return nativeAssetRepository.findProfileEntityIdInfos(DaoUtil.toPageable(pageLink)); + } + @Override public Long countByTenantId(TenantId tenantId) { return assetRepository.countByTenantId(tenantId.getId()); @@ -267,6 +292,16 @@ public class JpaAssetDao extends JpaAbstractDao implements A .map(AssetId::new).orElse(null); } + @Override + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findByTenantId(tenantId.getId(), pageLink); + } + + @Override + public List findNextBatch(UUID uuid, int batchSize) { + return assetRepository.findAllFields(uuid, Limit.of(batchSize)); + } + @Override public EntityType getEntityType() { return EntityType.ASSET; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetProfileDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetProfileDao.java index c64efe01c7..eeab5a338a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetProfileDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetProfileDao.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.sql.asset; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.PageRequest; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; @@ -23,11 +24,13 @@ import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.asset.AssetProfileInfo; +import org.thingsboard.server.common.data.edqs.fields.AssetProfileFields; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; import org.thingsboard.server.dao.asset.AssetProfileDao; import org.thingsboard.server.dao.model.sql.AssetProfileEntity; import org.thingsboard.server.dao.sql.JpaAbstractDao; @@ -37,7 +40,7 @@ import java.util.Optional; import java.util.UUID; @Component -public class JpaAssetProfileDao extends JpaAbstractDao implements AssetProfileDao { +public class JpaAssetProfileDao extends JpaAbstractDao implements AssetProfileDao, TenantEntityDao { @Autowired private AssetProfileRepository assetProfileRepository; @@ -138,6 +141,16 @@ public class JpaAssetProfileDao extends JpaAbstractDao findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findByTenantId(tenantId.getId(), pageLink); + } + + @Override + public List findNextBatch(UUID id, int batchSize) { + return assetProfileRepository.findNextBatch(id, Limit.of(batchSize)); + } + @Override public EntityType getEntityType() { return EntityType.ASSET_PROFILE; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java index f016de2878..e06975a9a5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java @@ -59,4 +59,12 @@ public interface AttributeKvRepository extends JpaRepository findAllKeysByEntityIdsAndAttributeType(@Param("entityIds") List entityIds, @Param("attributeType") int attributeType); + + @Query(value = "SELECT attribute_key, attribute_type, entity_id, bool_v, dbl_v, json_v, last_update_ts, long_v, str_v, version FROM attribute_kv WHERE (entity_id, attribute_type, attribute_key) > " + + "(:entityId, :attributeType, :attributeKey) ORDER BY entity_id, attribute_type, attribute_key LIMIT :batchSize", nativeQuery = true) + List findNextBatch(@Param("entityId") UUID entityId, + @Param("attributeType") int attributeType, + @Param("attributeKey") int attributeKey, + @Param("batchSize") int batchSize); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java index 9d477ee5b3..0a8b8f6399 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java @@ -49,6 +49,7 @@ import java.util.Collection; import java.util.Comparator; import java.util.List; import java.util.Optional; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; @@ -152,6 +153,11 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl return DaoUtil.convertDataList(Lists.newArrayList(attributes)); } + @Override + public List findNextBatch(UUID entityId, int attributeType, int attributeKey, int batchSize) { + return attributeKvRepository.findNextBatch(entityId, attributeType, attributeKey, batchSize); + } + @Override public List findAllKeysByDeviceProfileId(TenantId tenantId, DeviceProfileId deviceProfileId) { if (deviceProfileId != null) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldLinkRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldLinkRepository.java new file mode 100644 index 0000000000..584a3b5199 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldLinkRepository.java @@ -0,0 +1,30 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.sql.cf; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.thingsboard.server.dao.model.sql.CalculatedFieldLinkEntity; + +import java.util.List; +import java.util.UUID; + +public interface CalculatedFieldLinkRepository extends JpaRepository { + + List findAllByTenantIdAndCalculatedFieldId(UUID tenantId, UUID calculatedFieldId); + + List findAllByTenantIdAndEntityId(UUID tenantId, UUID entityId); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java new file mode 100644 index 0000000000..0f48f3b00d --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java @@ -0,0 +1,43 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.sql.cf; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.dao.model.sql.CalculatedFieldEntity; + +import java.util.List; +import java.util.UUID; + +public interface CalculatedFieldRepository extends JpaRepository { + + boolean existsByTenantIdAndEntityId(UUID tenantId, UUID entityId); + + List findCalculatedFieldIdsByTenantIdAndEntityId(UUID tenantId, UUID entityId); + + List findAllByTenantIdAndEntityId(UUID tenantId, UUID entityId); + + Page findAllByTenantIdAndEntityId(UUID tenantId, UUID entityId, Pageable pageable); + + List findAllByTenantId(UUID tenantId); + + List removeAllByTenantIdAndEntityId(UUID tenantId, UUID entityId); + + long countByTenantIdAndEntityId(UUID tenantId, UUID entityId); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java new file mode 100644 index 0000000000..e59ff3f4e6 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java @@ -0,0 +1,136 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.sql.cf; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.support.TransactionTemplate; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.debug.DebugSettings; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.CalculatedFieldLinkId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Repository +@Slf4j +public class DefaultNativeCalculatedFieldRepository implements NativeCalculatedFieldRepository { + + private final String CF_COUNT_QUERY = "SELECT count(id) FROM calculated_field;"; + private final String CF_QUERY = "SELECT * FROM calculated_field ORDER BY created_time ASC LIMIT %s OFFSET %s"; + + private final String CFL_COUNT_QUERY = "SELECT count(id) FROM calculated_field_link;"; + private final String CFL_QUERY = "SELECT * FROM calculated_field_link ORDER BY created_time ASC LIMIT %s OFFSET %s"; + + private final NamedParameterJdbcTemplate jdbcTemplate; + private final TransactionTemplate transactionTemplate; + + @Override + public PageData findCalculatedFields(Pageable pageable) { + return transactionTemplate.execute(status -> { + long startTs = System.currentTimeMillis(); + int totalElements = jdbcTemplate.queryForObject(CF_COUNT_QUERY, Collections.emptyMap(), Integer.class); + log.debug("Count query took {} ms", System.currentTimeMillis() - startTs); + startTs = System.currentTimeMillis(); + List> rows = jdbcTemplate.queryForList(String.format(CF_QUERY, pageable.getPageSize(), pageable.getOffset()), Collections.emptyMap()); + log.debug("Main query took {} ms", System.currentTimeMillis() - startTs); + int totalPages = pageable.getPageSize() > 0 ? (int) Math.ceil((float) totalElements / pageable.getPageSize()) : 1; + boolean hasNext = pageable.getPageSize() > 0 && totalElements > pageable.getOffset() + rows.size(); + var data = rows.stream().map(row -> { + + UUID id = (UUID) row.get("id"); + long createdTime = (long) row.get("created_time"); + UUID tenantId = (UUID) row.get("tenant_id"); + EntityType entityType = EntityType.valueOf((String) row.get("entity_type")); + UUID entityId = (UUID) row.get("entity_id"); + CalculatedFieldType type = CalculatedFieldType.valueOf((String) row.get("type")); + String name = (String) row.get("name"); + int configurationVersion = (int) row.get("configuration_version"); + JsonNode configuration = JacksonUtil.toJsonNode((String) row.get("configuration")); + long version = row.get("version") != null ? (long) row.get("version") : 0; + String debugSettings = (String) row.get("debug_settings"); + Object externalIdObj = row.get("external_id"); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setId(new CalculatedFieldId(id)); + calculatedField.setCreatedTime(createdTime); + calculatedField.setTenantId(TenantId.fromUUID(tenantId)); + calculatedField.setEntityId(EntityIdFactory.getByTypeAndUuid(entityType, entityId)); + calculatedField.setType(type); + calculatedField.setName(name); + calculatedField.setConfigurationVersion(configurationVersion); + calculatedField.setConfiguration(JacksonUtil.treeToValue(configuration, CalculatedFieldConfiguration.class)); + calculatedField.setVersion(version); + calculatedField.setDebugSettings(JacksonUtil.fromString(debugSettings, DebugSettings.class)); + + return calculatedField; + }).collect(Collectors.toList()); + return new PageData<>(data, totalPages, totalElements, hasNext); + }); + } + + @Override + public PageData findCalculatedFieldLinks(Pageable pageable) { + return transactionTemplate.execute(status -> { + long startTs = System.currentTimeMillis(); + int totalElements = jdbcTemplate.queryForObject(CFL_COUNT_QUERY, Collections.emptyMap(), Integer.class); + log.debug("Count query took {} ms", System.currentTimeMillis() - startTs); + startTs = System.currentTimeMillis(); + List> rows = jdbcTemplate.queryForList(String.format(CFL_QUERY, pageable.getPageSize(), pageable.getOffset()), Collections.emptyMap()); + log.debug("Main query took {} ms", System.currentTimeMillis() - startTs); + int totalPages = pageable.getPageSize() > 0 ? (int) Math.ceil((float) totalElements / pageable.getPageSize()) : 1; + boolean hasNext = pageable.getPageSize() > 0 && totalElements > pageable.getOffset() + rows.size(); + var data = rows.stream().map(row -> { + + UUID id = (UUID) row.get("id"); + long createdTime = (long) row.get("created_time"); + UUID tenantId = (UUID) row.get("tenant_id"); + EntityType entityType = EntityType.valueOf((String) row.get("entity_type")); + UUID entityId = (UUID) row.get("entity_id"); + UUID calculatedFieldId = (UUID) row.get("calculated_field_id"); + JsonNode configuration = JacksonUtil.toJsonNode((String) row.get("configuration")); + + CalculatedFieldLink calculatedFieldLink = new CalculatedFieldLink(); + calculatedFieldLink.setId(new CalculatedFieldLinkId(id)); + calculatedFieldLink.setCreatedTime(createdTime); + calculatedFieldLink.setTenantId(new TenantId(tenantId)); + calculatedFieldLink.setEntityId(EntityIdFactory.getByTypeAndUuid(entityType, entityId)); + calculatedFieldLink.setCalculatedFieldId(new CalculatedFieldId(calculatedFieldId)); + + return calculatedFieldLink; + }).collect(Collectors.toList()); + return new PageData<>(data, totalPages, totalElements, hasNext); + }); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java new file mode 100644 index 0000000000..8922eaca4e --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java @@ -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. + */ +package org.thingsboard.server.dao.sql.cf; + +import jakarta.transaction.Transactional; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.cf.CalculatedFieldDao; +import org.thingsboard.server.dao.model.sql.CalculatedFieldEntity; +import org.thingsboard.server.dao.sql.JpaAbstractDao; +import org.thingsboard.server.dao.util.SqlDao; + +import java.util.List; +import java.util.UUID; + +@Slf4j +@Component +@AllArgsConstructor +@SqlDao +public class JpaCalculatedFieldDao extends JpaAbstractDao implements CalculatedFieldDao { + + private final CalculatedFieldRepository calculatedFieldRepository; + private final NativeCalculatedFieldRepository nativeCalculatedFieldRepository; + + @Override + public List findAllByTenantId(TenantId tenantId) { + return DaoUtil.convertDataList(calculatedFieldRepository.findAllByTenantId(tenantId.getId())); + } + + @Override + public List findCalculatedFieldIdsByEntityId(TenantId tenantId, EntityId entityId) { + return calculatedFieldRepository.findCalculatedFieldIdsByTenantIdAndEntityId(tenantId.getId(), entityId.getId()); + } + + @Override + public List findCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId) { + return DaoUtil.convertDataList(calculatedFieldRepository.findAllByTenantIdAndEntityId(tenantId.getId(), entityId.getId())); + } + + @Override + public List findAll() { + return DaoUtil.convertDataList(calculatedFieldRepository.findAll()); + } + + @Override + public PageData findAll(PageLink pageLink) { + log.debug("Try to find calculated fields by pageLink [{}]", pageLink); + return nativeCalculatedFieldRepository.findCalculatedFields(DaoUtil.toPageable(pageLink)); + } + + @Override + public PageData findAllByEntityId(TenantId tenantId, EntityId entityId, PageLink pageLink) { + log.debug("Try to find calculated fields by entityId[{}] and pageLink [{}]", entityId, pageLink); + return DaoUtil.toPageData(calculatedFieldRepository.findAllByTenantIdAndEntityId(tenantId.getId(), entityId.getId(), DaoUtil.toPageable(pageLink))); + } + + @Override + @Transactional + public List removeAllByEntityId(TenantId tenantId, EntityId entityId) { + return DaoUtil.convertDataList(calculatedFieldRepository.removeAllByTenantIdAndEntityId(tenantId.getId(), entityId.getId())); + } + + @Override + public long countCFByEntityId(TenantId tenantId, EntityId entityId) { + return calculatedFieldRepository.countByTenantIdAndEntityId(tenantId.getId(), entityId.getId()); + } + + @Override + protected Class getEntityClass() { + return CalculatedFieldEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return calculatedFieldRepository; + } + + @Override + public EntityType getEntityType() { + return EntityType.CALCULATED_FIELD; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldLinkDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldLinkDao.java new file mode 100644 index 0000000000..dbb2fd87da --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldLinkDao.java @@ -0,0 +1,83 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.sql.cf; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.cf.CalculatedFieldLinkDao; +import org.thingsboard.server.dao.model.sql.CalculatedFieldLinkEntity; +import org.thingsboard.server.dao.sql.JpaAbstractDao; +import org.thingsboard.server.dao.util.SqlDao; + +import java.util.List; +import java.util.UUID; + +@Slf4j +@Component +@AllArgsConstructor +@SqlDao +public class JpaCalculatedFieldLinkDao extends JpaAbstractDao implements CalculatedFieldLinkDao { + + private final CalculatedFieldLinkRepository calculatedFieldLinkRepository; + private final NativeCalculatedFieldRepository nativeCalculatedFieldRepository; + + @Override + public List findCalculatedFieldLinksByCalculatedFieldId(TenantId tenantId, CalculatedFieldId calculatedFieldId) { + return DaoUtil.convertDataList(calculatedFieldLinkRepository.findAllByTenantIdAndCalculatedFieldId(tenantId.getId(), calculatedFieldId.getId())); + } + + @Override + public List findCalculatedFieldLinksByEntityId(TenantId tenantId, EntityId entityId) { + return DaoUtil.convertDataList(calculatedFieldLinkRepository.findAllByTenantIdAndEntityId(tenantId.getId(), entityId.getId())); + } + + @Override + public List findAll() { + return DaoUtil.convertDataList(calculatedFieldLinkRepository.findAll()); + } + + @Override + public PageData findAll(PageLink pageLink) { + log.debug("Try to find calculated field links by pageLink [{}]", pageLink); + return nativeCalculatedFieldRepository.findCalculatedFieldLinks(DaoUtil.toPageable(pageLink)); + } + + @Override + protected Class getEntityClass() { + return CalculatedFieldLinkEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return calculatedFieldLinkRepository; + } + + @Override + public EntityType getEntityType() { + return EntityType.CALCULATED_FIELD_LINK; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/NativeCalculatedFieldRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/NativeCalculatedFieldRepository.java new file mode 100644 index 0000000000..f37a5764a0 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/NativeCalculatedFieldRepository.java @@ -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. + */ +package org.thingsboard.server.dao.sql.cf; + +import org.springframework.data.domain.Pageable; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.page.PageData; + +public interface NativeCalculatedFieldRepository { + + PageData findCalculatedFields(Pageable pageable); + + PageData findCalculatedFieldLinks(Pageable pageable); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java index af4c96fa63..8ad7311423 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java @@ -15,14 +15,17 @@ */ package org.thingsboard.server.dao.sql.customer; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.edqs.fields.CustomerFields; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.CustomerEntity; +import java.util.List; import java.util.UUID; /** @@ -55,4 +58,8 @@ public interface CustomerRepository extends JpaRepository, nativeQuery = true) Page findCustomersWithTheSameTitle(Pageable pageable); + @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.CustomerFields(c.id, c.createdTime, c.tenantId, " + + "c.title, c.version, c.additionalInfo, c.country, c.state, c.city, c.address, c.address2, c.zip, c.phone, c.email) " + + "FROM CustomerEntity c WHERE c.id > :id ORDER BY c.id") + List findNextBatch(@Param("id") UUID id, Limit limit); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java index 4c3d0083a6..75e7179391 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java @@ -16,10 +16,12 @@ package org.thingsboard.server.dao.sql.customer; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.fields.CustomerFields; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -30,6 +32,7 @@ import org.thingsboard.server.dao.model.sql.CustomerEntity; import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.util.SqlDao; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -104,6 +107,16 @@ public class JpaCustomerDao extends JpaAbstractDao imp ); } + @Override + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findByTenantId(tenantId.getId(), pageLink); + } + + @Override + public List findNextBatch(UUID id, int batchSize) { + return customerRepository.findNextBatch(id, Limit.of(batchSize)); + } + @Override public EntityType getEntityType() { return EntityType.CUSTOMER; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardRepository.java index 94b6cd541c..f4e934e64b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardRepository.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.dao.sql.dashboard; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.edqs.fields.DashboardFields; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.DashboardEntity; @@ -46,4 +48,7 @@ public interface DashboardRepository extends JpaRepository findAllIds(Pageable pageable); + @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.DashboardFields(d.id, d.createdTime, d.tenantId, " + + "d.assignedCustomers, d.title, d.version) FROM DashboardEntity d WHERE d.id > :id ORDER BY d.id") + List findNextBatch(@Param("id") UUID id, Limit limit); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardDao.java index ea78114ead..2d796c6917 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardDao.java @@ -16,10 +16,12 @@ package org.thingsboard.server.dao.sql.dashboard; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.fields.DashboardFields; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -90,6 +92,16 @@ public class JpaDashboardDao extends JpaAbstractDao return DaoUtil.pageToPageData(dashboardRepository.findAllIds(DaoUtil.toPageable(pageLink)).map(DashboardId::new)); } + @Override + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findByTenantId(tenantId.getId(), pageLink); + } + + @Override + public List findNextBatch(UUID id, int batchSize) { + return dashboardRepository.findNextBatch(id, Limit.of(batchSize)); + } + @Override public EntityType getEntityType() { return EntityType.DASHBOARD; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/AbstractNativeRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/AbstractNativeRepository.java new file mode 100644 index 0000000000..bba84503f4 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/AbstractNativeRepository.java @@ -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. + */ +package org.thingsboard.server.dao.sql.device; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.support.TransactionTemplate; +import org.thingsboard.server.common.data.page.PageData; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Repository +@Slf4j +public class AbstractNativeRepository { + + private final NamedParameterJdbcTemplate jdbcTemplate; + private final TransactionTemplate transactionTemplate; + + protected PageData find(String countQuery, String findQuery, Pageable pageable, Function, T> mapper) { + return transactionTemplate.execute(status -> { + long startTs = System.currentTimeMillis(); + int totalElements = jdbcTemplate.queryForObject(countQuery, Collections.emptyMap(), Integer.class); + log.debug("Count query took {} ms", System.currentTimeMillis() - startTs); + startTs = System.currentTimeMillis(); + List> rows = jdbcTemplate.queryForList(String.format(findQuery, pageable.getPageSize(), pageable.getOffset()), Collections.emptyMap()); + log.debug("Main query took {} ms", System.currentTimeMillis() - startTs); + int totalPages = pageable.getPageSize() > 0 ? (int) Math.ceil((float) totalElements / pageable.getPageSize()) : 1; + boolean hasNext = pageable.getPageSize() > 0 && totalElements > pageable.getOffset() + rows.size(); + var data = rows.stream().map(mapper).collect(Collectors.toList()); + return new PageData<>(data, totalPages, totalElements, hasNext); + }); + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeAssetRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeAssetRepository.java new file mode 100644 index 0000000000..43d66e2ff0 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeAssetRepository.java @@ -0,0 +1,55 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.sql.device; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.support.TransactionTemplate; +import org.thingsboard.server.common.data.DeviceIdInfo; +import org.thingsboard.server.common.data.ProfileEntityIdInfo; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; + +import java.util.UUID; + +@Repository +@Slf4j +public class DefaultNativeAssetRepository extends AbstractNativeRepository implements NativeAssetRepository { + + private final String COUNT_QUERY = "SELECT count(id) FROM asset;"; + + public DefaultNativeAssetRepository(NamedParameterJdbcTemplate jdbcTemplate, TransactionTemplate transactionTemplate) { + super(jdbcTemplate, transactionTemplate); + } + + @Override + public PageData findProfileEntityIdInfos(Pageable pageable) { + String PROFILE_DEVICE_ID_INFO_QUERY = "SELECT tenant_id as tenantId, asset_profile_id as profileId, id as id FROM asset ORDER BY created_time ASC LIMIT %s OFFSET %s"; + return find(COUNT_QUERY, PROFILE_DEVICE_ID_INFO_QUERY, pageable, row -> { + AssetId id = new AssetId((UUID) row.get("id")); + AssetProfileId profileId = new AssetProfileId((UUID) row.get("profileId")); + var tenantIdObj = row.get("tenantId"); + return ProfileEntityIdInfo.create(tenantIdObj != null ? (UUID) tenantIdObj : TenantId.SYS_TENANT_ID.getId(), profileId, id); + }); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeDeviceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeDeviceRepository.java index 4556ac555e..776dedc2d5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeDeviceRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeDeviceRepository.java @@ -15,50 +15,50 @@ */ package org.thingsboard.server.dao.sql.device; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Pageable; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; import org.springframework.transaction.support.TransactionTemplate; import org.thingsboard.server.common.data.DeviceIdInfo; +import org.thingsboard.server.common.data.ProfileEntityIdInfo; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; -import java.util.Collections; -import java.util.List; -import java.util.Map; import java.util.UUID; -import java.util.stream.Collectors; -@RequiredArgsConstructor @Repository @Slf4j -public class DefaultNativeDeviceRepository implements NativeDeviceRepository { +public class DefaultNativeDeviceRepository extends AbstractNativeRepository implements NativeDeviceRepository { private final String COUNT_QUERY = "SELECT count(id) FROM device;"; - private final String QUERY = "SELECT tenant_id as tenantId, customer_id as customerId, id as id FROM device ORDER BY created_time ASC LIMIT %s OFFSET %s"; - private final NamedParameterJdbcTemplate jdbcTemplate; - private final TransactionTemplate transactionTemplate; + + public DefaultNativeDeviceRepository(NamedParameterJdbcTemplate jdbcTemplate, TransactionTemplate transactionTemplate) { + super(jdbcTemplate, transactionTemplate); + } @Override public PageData findDeviceIdInfos(Pageable pageable) { - return transactionTemplate.execute(status -> { - long startTs = System.currentTimeMillis(); - int totalElements = jdbcTemplate.queryForObject(COUNT_QUERY, Collections.emptyMap(), Integer.class); - log.debug("Count query took {} ms", System.currentTimeMillis() - startTs); - startTs = System.currentTimeMillis(); - List> rows = jdbcTemplate.queryForList(String.format(QUERY, pageable.getPageSize(), pageable.getOffset()), Collections.emptyMap()); - log.debug("Main query took {} ms", System.currentTimeMillis() - startTs); - int totalPages = pageable.getPageSize() > 0 ? (int) Math.ceil((float) totalElements / pageable.getPageSize()) : 1; - boolean hasNext = pageable.getPageSize() > 0 && totalElements > pageable.getOffset() + rows.size(); - var data = rows.stream().map(row -> { - UUID id = (UUID) row.get("id"); - var tenantIdObj = row.get("tenantId"); - var customerIdObj = row.get("customerId"); - return new DeviceIdInfo(tenantIdObj != null ? (UUID) tenantIdObj : TenantId.SYS_TENANT_ID.getId(), customerIdObj != null ? (UUID) customerIdObj : null, id); - }).collect(Collectors.toList()); - return new PageData<>(data, totalPages, totalElements, hasNext); + String DEVICE_ID_INFO_QUERY = "SELECT tenant_id as tenantId, customer_id as customerId, id as id FROM device ORDER BY created_time ASC LIMIT %s OFFSET %s"; + return find(COUNT_QUERY, DEVICE_ID_INFO_QUERY, pageable, row -> { + UUID id = (UUID) row.get("id"); + var tenantIdObj = row.get("tenantId"); + var customerIdObj = row.get("customerId"); + return new DeviceIdInfo(tenantIdObj != null ? (UUID) tenantIdObj : TenantId.SYS_TENANT_ID.getId(), customerIdObj != null ? (UUID) customerIdObj : null, id); }); } + + @Override + public PageData findProfileEntityIdInfos(Pageable pageable) { + String PROFILE_DEVICE_ID_INFO_QUERY = "SELECT tenant_id as tenantId, device_profile_id as profileId, id as id FROM device ORDER BY created_time ASC LIMIT %s OFFSET %s"; + return find(COUNT_QUERY, PROFILE_DEVICE_ID_INFO_QUERY, pageable, row -> { + DeviceId id = new DeviceId((UUID) row.get("id")); + DeviceProfileId profileId = new DeviceProfileId((UUID) row.get("profileId")); + var tenantIdObj = row.get("tenantId"); + return ProfileEntityIdInfo.create(tenantIdObj != null ? (UUID) tenantIdObj : TenantId.SYS_TENANT_ID.getId(), profileId, id); + }); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceCredentialsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceCredentialsRepository.java index 2f84d24420..7b938ea274 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceCredentialsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceCredentialsRepository.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.dao.sql.device; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -36,4 +38,8 @@ public interface DeviceCredentialsRepository extends JpaRepository findByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceProfileRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceProfileRepository.java index 34dac8c7a4..88d4780f9d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceProfileRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceProfileRepository.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.sql.device; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -23,6 +24,7 @@ import org.springframework.data.repository.query.Param; import org.thingsboard.server.common.data.DeviceProfileInfo; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityInfo; +import org.thingsboard.server.common.data.edqs.fields.DeviceProfileFields; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.DeviceProfileEntity; @@ -92,4 +94,7 @@ public interface DeviceProfileRepository extends JpaRepository findAllTenantDeviceProfileNames(@Param("tenantId") UUID tenantId); + @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.DeviceProfileFields(d.id, d.createdTime, d.tenantId," + + "d.name, d.version, d.type, d.isDefault) FROM DeviceProfileEntity d WHERE d.id > :id ORDER BY d.id") + List findNextBatch(@Param("id") UUID id, Limit limit); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java index 86048a9014..f4c2fed9fa 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.dao.sql.device; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.edqs.fields.DeviceFields; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.DeviceEntity; import org.thingsboard.server.dao.model.sql.DeviceInfoEntity; @@ -81,6 +83,14 @@ public interface DeviceRepository extends JpaRepository, Exp @Param("textSearch") String textSearch, Pageable pageable); + @Query("SELECT d.id FROM DeviceEntity d WHERE d.tenantId = :tenantId " + + "AND d.deviceProfileId = :deviceProfileId " + + "AND (:textSearch IS NULL OR ilike(d.type, CONCAT('%', :textSearch, '%')) = true)") + Page findIdsByTenantIdAndDeviceProfileId(@Param("tenantId") UUID tenantId, + @Param("deviceProfileId") UUID deviceProfileId, + @Param("textSearch") String textSearch, + Pageable pageable); + @Query("SELECT d FROM DeviceEntity d WHERE d.tenantId = :tenantId " + "AND d.deviceProfileId = :deviceProfileId " + "AND d.firmwareId IS NULL") @@ -194,4 +204,9 @@ public interface DeviceRepository extends JpaRepository, Exp @Query("SELECT externalId FROM DeviceEntity WHERE id = :id") UUID getExternalIdById(@Param("id") UUID id); + + @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.DeviceFields(d.id, d.createdTime, d.tenantId, d.customerId," + + "d.name, d.version, d.type, d.label, d.deviceProfileId, d.additionalInfo) FROM DeviceEntity d WHERE d.id > :id ORDER BY d.id") + List findNextBatch(@Param("id") UUID id, Limit limit); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDao.java index 6d9093dc0b..7445d058d4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDao.java @@ -21,8 +21,11 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; import org.thingsboard.server.dao.device.DeviceCredentialsDao; import org.thingsboard.server.dao.model.sql.DeviceCredentialsEntity; import org.thingsboard.server.dao.sql.JpaAbstractDao; @@ -36,7 +39,7 @@ import java.util.UUID; @Slf4j @Component @SqlDao -public class JpaDeviceCredentialsDao extends JpaAbstractDao implements DeviceCredentialsDao { +public class JpaDeviceCredentialsDao extends JpaAbstractDao implements DeviceCredentialsDao, TenantEntityDao { @Autowired private DeviceCredentialsRepository deviceCredentialsRepository; @@ -67,4 +70,9 @@ public class JpaDeviceCredentialsDao extends JpaAbstractDao findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return DaoUtil.toPageData(deviceCredentialsRepository.findByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink))); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java index ece841de6c..34835f52f1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.sql.device; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -29,7 +30,9 @@ import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.edqs.fields.DeviceFields; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.ota.OtaPackageType; @@ -173,6 +176,17 @@ public class JpaDeviceDao extends JpaAbstractDao implement DaoUtil.toPageable(pageLink))); } + @Override + public PageData findDeviceIdsByTenantIdAndDeviceProfileId(UUID tenantId, UUID deviceProfileId, PageLink pageLink) { + return DaoUtil.pageToPageData( + deviceRepository.findIdsByTenantIdAndDeviceProfileId( + tenantId, + deviceProfileId, + pageLink.getTextSearch(), + DaoUtil.toPageable(pageLink))) + .mapData(DeviceId::new); + } + @Override public PageData findDevicesByTenantIdAndTypeAndEmptyOtaPackage(UUID tenantId, UUID deviceProfileId, @@ -261,6 +275,12 @@ public class JpaDeviceDao extends JpaAbstractDao implement return nativeDeviceRepository.findDeviceIdInfos(DaoUtil.toPageable(pageLink)); } + @Override + public PageData findProfileEntityIdInfos(PageLink pageLink) { + log.debug("Find profile device id infos by pageLink [{}]", pageLink); + return nativeDeviceRepository.findProfileEntityIdInfos(DaoUtil.toPageable(pageLink)); + } + @Override public Device findByTenantIdAndExternalId(UUID tenantId, UUID externalId) { return DaoUtil.getData(deviceRepository.findByTenantIdAndExternalId(tenantId, externalId)); @@ -282,6 +302,16 @@ public class JpaDeviceDao extends JpaAbstractDao implement .map(DeviceId::new).orElse(null); } + @Override + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findByTenantId(tenantId.getId(), pageLink); + } + + @Override + public List findNextBatch(UUID id, int batchSize) { + return deviceRepository.findNextBatch(id, Limit.of(batchSize)); + } + @Override public EntityType getEntityType() { return EntityType.DEVICE; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceProfileDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceProfileDao.java index d68033d14b..ebd8c78ed0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceProfileDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceProfileDao.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.sql.device; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.PageRequest; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; @@ -25,11 +26,13 @@ import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.edqs.fields.DeviceProfileFields; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; import org.thingsboard.server.dao.device.DeviceProfileDao; import org.thingsboard.server.dao.model.sql.DeviceProfileEntity; import org.thingsboard.server.dao.sql.JpaAbstractDao; @@ -41,7 +44,7 @@ import java.util.UUID; @Component @SqlDao -public class JpaDeviceProfileDao extends JpaAbstractDao implements DeviceProfileDao { +public class JpaDeviceProfileDao extends JpaAbstractDao implements DeviceProfileDao, TenantEntityDao { @Autowired private DeviceProfileRepository deviceProfileRepository; @@ -156,6 +159,16 @@ public class JpaDeviceProfileDao extends JpaAbstractDao findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findDeviceProfiles(tenantId, pageLink); + } + + @Override + public List findNextBatch(UUID id, int batchSize) { + return deviceProfileRepository.findNextBatch(id, Limit.of(batchSize)); + } + @Override public EntityType getEntityType() { return EntityType.DEVICE_PROFILE; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/NativeAssetRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/NativeAssetRepository.java new file mode 100644 index 0000000000..42f6b4c819 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/NativeAssetRepository.java @@ -0,0 +1,18 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.sql.device; + +public interface NativeAssetRepository extends NativeProfileEntityRepository {} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/NativeDeviceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/NativeDeviceRepository.java index 53f70ed046..b9fcf75ba4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/NativeDeviceRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/NativeDeviceRepository.java @@ -17,9 +17,10 @@ package org.thingsboard.server.dao.sql.device; import org.springframework.data.domain.Pageable; import org.thingsboard.server.common.data.DeviceIdInfo; +import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.page.PageData; -public interface NativeDeviceRepository { +public interface NativeDeviceRepository extends NativeProfileEntityRepository { PageData findDeviceIdInfos(Pageable pageable); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/NativeProfileEntityRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/NativeProfileEntityRepository.java new file mode 100644 index 0000000000..750f0c8787 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/NativeProfileEntityRepository.java @@ -0,0 +1,26 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.sql.device; + +import org.springframework.data.domain.Pageable; +import org.thingsboard.server.common.data.ProfileEntityIdInfo; +import org.thingsboard.server.common.data.page.PageData; + +public interface NativeProfileEntityRepository { + + PageData findProfileEntityIdInfos(Pageable pageable); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/edge/EdgeRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/edge/EdgeRepository.java index 245b47ae63..c11db0a348 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/edge/EdgeRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/edge/EdgeRepository.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.dao.sql.edge; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.edqs.fields.EdgeFields; import org.thingsboard.server.dao.model.sql.EdgeEntity; import org.thingsboard.server.dao.model.sql.EdgeInfoEntity; @@ -154,4 +156,7 @@ public interface EdgeRepository extends JpaRepository { EdgeEntity findByRoutingKey(String routingKey); + @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.EdgeFields(e.id, e.createdTime, e.tenantId, e.customerId," + + "e.name, e.version, e.type, e.label, e.additionalInfo) FROM EdgeEntity e WHERE e.id > :id ORDER BY e.id") + List findNextBatch(@Param("id") UUID id, Limit limit); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaEdgeDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaEdgeDao.java index b1c51c73c1..3f45b1ca1a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaEdgeDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaEdgeDao.java @@ -18,12 +18,14 @@ package org.thingsboard.server.dao.sql.edge; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeInfo; +import org.thingsboard.server.common.data.edqs.fields.EdgeFields; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -219,6 +221,16 @@ public class JpaEdgeDao extends JpaAbstractDao implements Edge return edgeRepository.countByTenantId(tenantId.getId()); } + @Override + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findEdgesByTenantId(tenantId.getId(), pageLink); + } + + @Override + public List findNextBatch(UUID id, int batchSize) { + return edgeRepository.findNextBatch(id, Limit.of(batchSize)); + } + @Override public EntityType getEntityType() { return EntityType.EDGE; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java index 0112be4967..6094e9b171 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.dao.sql.entityview; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.edqs.fields.EntityViewFields; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.EntityViewEntity; import org.thingsboard.server.dao.model.sql.EntityViewInfoEntity; @@ -145,4 +147,8 @@ public interface EntityViewRepository extends JpaRepository :id ORDER BY e.id") + List findNextBatch(@Param("id") UUID id, Limit limit); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java index a6c4457f4f..44d8a09ff4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java @@ -18,17 +18,20 @@ package org.thingsboard.server.dao.sql.entityview; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityViewInfo; +import org.thingsboard.server.common.data.edqs.fields.EntityViewFields; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; import org.thingsboard.server.dao.entityview.EntityViewDao; import org.thingsboard.server.dao.model.sql.EntityViewEntity; import org.thingsboard.server.dao.model.sql.EntityViewInfoEntity; @@ -47,8 +50,7 @@ import static org.thingsboard.server.dao.DaoUtil.convertTenantEntityTypesToDto; @Component @Slf4j @SqlDao -public class JpaEntityViewDao extends JpaAbstractDao - implements EntityViewDao { +public class JpaEntityViewDao extends JpaAbstractDao implements EntityViewDao, TenantEntityDao { @Autowired private EntityViewRepository entityViewRepository; @@ -218,8 +220,19 @@ public class JpaEntityViewDao extends JpaAbstractDao findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findByTenantId(tenantId.getId(), pageLink); + } + + @Override + public List findNextBatch(UUID id, int batchSize) { + return entityViewRepository.findNextBatch(id, Limit.of(batchSize)); + } + @Override public EntityType getEntityType() { return EntityType.ENTITY_VIEW; } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/event/CalculatedFieldDebugEventRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/event/CalculatedFieldDebugEventRepository.java new file mode 100644 index 0000000000..482905d362 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/event/CalculatedFieldDebugEventRepository.java @@ -0,0 +1,145 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.sql.event; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.common.data.event.CalculatedFieldDebugEvent; +import org.thingsboard.server.dao.model.sql.CalculatedFieldDebugEventEntity; + +import java.util.List; +import java.util.UUID; + +public interface CalculatedFieldDebugEventRepository extends EventRepository, JpaRepository { + + @Override + @Query(nativeQuery = true, value = "SELECT * FROM cf_debug_event e WHERE e.tenant_id = :tenantId AND e.entity_id = :entityId ORDER BY e.ts DESC LIMIT :limit") + List findLatestEvents(@Param("tenantId") UUID tenantId, @Param("entityId") UUID entityId, @Param("limit") int limit); + + @Override + @Query("SELECT e FROM CalculatedFieldDebugEventEntity e WHERE " + + "e.tenantId = :tenantId " + + "AND e.entityId = :entityId " + + "AND (:startTime IS NULL OR e.ts >= :startTime) " + + "AND (:endTime IS NULL OR e.ts <= :endTime)" + ) + Page findEvents(@Param("tenantId") UUID tenantId, + @Param("entityId") UUID entityId, + @Param("startTime") Long startTime, + @Param("endTime") Long endTime, + Pageable pageable); + + @Query(nativeQuery = true, + value = "SELECT * FROM cf_debug_event e WHERE " + + "e.tenant_id = :tenantId " + + "AND e.entity_id = :entityId " + + "AND (:startTime IS NULL OR e.ts >= :startTime) " + + "AND (:endTime IS NULL OR e.ts <= :endTime) " + + "AND (:serviceId IS NULL OR e.service_id ILIKE concat('%', :serviceId, '%')) " + + "AND (:calculatedFieldId IS NULL OR e.cf_id = uuid(:calculatedFieldId)) " + + "AND (:eventEntityId IS NULL OR e.e_entity_id = uuid(:eventEntityId)) " + + "AND (:eventEntityType IS NULL OR e.e_entity_type ILIKE concat('%', :eventEntityType, '%')) " + + "AND (:msgId IS NULL OR e.e_msg_id = uuid(:msgId)) " + + "AND (:msgType IS NULL OR e.e_msg_type ILIKE concat('%', :msgType, '%')) " + + "AND (:eventArguments IS NULL OR e.e_args ILIKE concat('%', :eventArguments, '%')) " + + "AND (:eventResult IS NULL OR e.e_result ILIKE concat('%', :eventResult, '%')) " + + "AND ((:isError = FALSE) OR e.e_error IS NOT NULL) " + + "AND (:error IS NULL OR e.e_error ILIKE concat('%', :error, '%'))" + , + countQuery = "SELECT count(*) FROM cf_debug_event e WHERE " + + "e.tenant_id = :tenantId " + + "AND e.entity_id = :entityId " + + "AND (:startTime IS NULL OR e.ts >= :startTime) " + + "AND (:endTime IS NULL OR e.ts <= :endTime) " + + "AND (:serviceId IS NULL OR e.service_id ILIKE concat('%', :serviceId, '%')) " + + "AND (:calculatedFieldId IS NULL OR e.cf_id = uuid(:calculatedFieldId)) " + + "AND (:eventEntityId IS NULL OR e.e_entity_id = uuid(:eventEntityId)) " + + "AND (:eventEntityType IS NULL OR e.e_entity_type ILIKE concat('%', :eventEntityType, '%')) " + + "AND (:msgId IS NULL OR e.e_msg_id = uuid(:msgId)) " + + "AND (:msgType IS NULL OR e.e_msg_type ILIKE concat('%', :msgType, '%')) " + + "AND (:eventArguments IS NULL OR e.e_args ILIKE concat('%', :eventArguments, '%')) " + + "AND (:eventResult IS NULL OR e.e_result ILIKE concat('%', :eventResult, '%')) " + + "AND ((:isError = FALSE) OR e.e_error IS NOT NULL) " + + "AND (:error IS NULL OR e.e_error ILIKE concat('%', :error, '%'))" + ) + Page findEvents(@Param("tenantId") UUID tenantId, + @Param("entityId") UUID entityId, + @Param("startTime") Long startTime, + @Param("endTime") Long endTime, + @Param("serviceId") String serviceId, + @Param("calculatedFieldId") UUID calculatedFieldId, + @Param("eventEntityId") String eventEntityId, + @Param("eventEntityType") String eventEntityType, + @Param("msgId") String eventMsgId, + @Param("msgType") String eventMsgType, + @Param("eventArguments") String eventArguments, + @Param("eventResult") String eventResult, + @Param("isError") boolean isError, + @Param("error") String error, + Pageable pageable); + + @Transactional + @Modifying + @Query("DELETE FROM CalculatedFieldDebugEventEntity e WHERE " + + "e.tenantId = :tenantId " + + "AND e.entityId = :entityId " + + "AND (:startTime IS NULL OR e.ts >= :startTime) " + + "AND (:endTime IS NULL OR e.ts <= :endTime)" + ) + void removeEvents(@Param("tenantId") UUID tenantId, + @Param("entityId") UUID entityId, + @Param("startTime") Long startTime, + @Param("endTime") Long endTime); + + @Transactional + @Modifying + @Query(nativeQuery = true, + value = "DELETE FROM cf_debug_event e WHERE " + + "e.tenant_id = :tenantId " + + "AND e.entity_id = :entityId " + + "AND (:startTime IS NULL OR e.ts >= :startTime) " + + "AND (:endTime IS NULL OR e.ts <= :endTime) " + + "AND (:serviceId IS NULL OR e.service_id ILIKE concat('%', :serviceId, '%')) " + + "AND (:calculatedFieldId IS NULL OR e.cf_id = uuid(:calculatedFieldId)) " + + "AND (:eventEntityId IS NULL OR e.e_entity_id = uuid(:eventEntityId)) " + + "AND (:eventEntityType IS NULL OR e.e_entity_type ILIKE concat('%', :eventEntityType, '%')) " + + "AND (:msgId IS NULL OR e.e_msg_id = uuid(:msgId)) " + + "AND (:msgType IS NULL OR e.e_msg_type ILIKE concat('%', :msgType, '%')) " + + "AND (:eventArguments IS NULL OR e.e_args ILIKE concat('%', :eventArguments, '%')) " + + "AND (:eventResult IS NULL OR e.e_result ILIKE concat('%', :eventResult, '%')) " + + "AND ((:isError = FALSE) OR e.e_error IS NOT NULL) " + + "AND (:error IS NULL OR e.e_error ILIKE concat('%', :error, '%'))") + void removeEvents(@Param("tenantId") UUID tenantId, + @Param("entityId") UUID entityId, + @Param("startTime") Long startTime, + @Param("endTime") Long endTime, + @Param("serviceId") String serviceId, + @Param("calculatedFieldId") UUID calculatedFieldId, + @Param("eventEntityId") String eventEntityId, + @Param("eventEntityType") String eventEntityType, + @Param("msgId") String eventMsgId, + @Param("msgType") String eventMsgType, + @Param("eventArguments") String eventArguments, + @Param("eventResult") String eventResult, + @Param("isError") boolean isError, + @Param("error") String error); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/event/DedicatedJpaEventDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/event/DedicatedJpaEventDao.java index abe95fb8f4..e13dfacf81 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/event/DedicatedJpaEventDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/event/DedicatedJpaEventDao.java @@ -36,10 +36,11 @@ public class DedicatedJpaEventDao extends JpaBaseEventDao { RuleNodeDebugEventRepository ruleNodeDebugEventRepository, RuleChainDebugEventRepository ruleChainDebugEventRepository, ScheduledLogExecutorComponent logExecutor, - StatsFactory statsFactory) { + StatsFactory statsFactory, + CalculatedFieldDebugEventRepository cfDebugEventRepository) { super(partitionConfiguration, partitioningRepository, lcEventRepository, statsEventRepository, errorEventRepository, eventInsertRepository, ruleNodeDebugEventRepository, - ruleChainDebugEventRepository, logExecutor, statsFactory); + ruleChainDebugEventRepository, logExecutor, statsFactory, cfDebugEventRepository); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/event/EventInsertRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/event/EventInsertRepository.java index cd25577214..142924eac6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/event/EventInsertRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/event/EventInsertRepository.java @@ -25,6 +25,7 @@ import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionCallbackWithoutResult; import org.springframework.transaction.support.TransactionTemplate; +import org.thingsboard.server.common.data.event.CalculatedFieldDebugEvent; import org.thingsboard.server.common.data.event.ErrorEvent; import org.thingsboard.server.common.data.event.Event; import org.thingsboard.server.common.data.event.EventType; @@ -81,6 +82,9 @@ public class EventInsertRepository { insertStmtMap.put(EventType.DEBUG_RULE_CHAIN, "INSERT INTO " + EventType.DEBUG_RULE_CHAIN.getTable() + " (id, tenant_id, ts, entity_id, service_id, e_message, e_error) " + "VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING;"); + insertStmtMap.put(EventType.DEBUG_CALCULATED_FIELD, "INSERT INTO " + EventType.DEBUG_CALCULATED_FIELD.getTable() + + " (id, tenant_id, ts, entity_id, service_id, cf_id, e_entity_id, e_entity_type, e_msg_id, e_msg_type, e_args, e_result, e_error) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING;"); } public void save(List entities) { @@ -107,6 +111,8 @@ public class EventInsertRepository { return getRuleNodeEventSetter(events); case DEBUG_RULE_CHAIN: return getRuleChainEventSetter(events); + case DEBUG_CALCULATED_FIELD: + return getCalculatedFieldEventSetter(events); default: throw new RuntimeException(eventType + " support is not implemented!"); } @@ -206,6 +212,29 @@ public class EventInsertRepository { }; } + private BatchPreparedStatementSetter getCalculatedFieldEventSetter(List events) { + return new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + CalculatedFieldDebugEvent event = (CalculatedFieldDebugEvent) events.get(i); + setCommonEventFields(ps, event); + safePutUUID(ps, 6, event.getCalculatedFieldId().getId()); + safePutUUID(ps, 7, event.getEventEntity() != null ? event.getEventEntity().getId() : null); + safePutString(ps, 8, event.getEventEntity() != null ? event.getEventEntity().getEntityType().name() : null); + safePutUUID(ps, 9, event.getMsgId()); + safePutString(ps, 10, event.getMsgType()); + safePutString(ps, 11, event.getArguments()); + safePutString(ps, 12, event.getResult()); + safePutString(ps, 13, event.getError()); + } + + @Override + public int getBatchSize() { + return events.size(); + } + }; + } + void safePutString(PreparedStatement ps, int parameterIdx, String value) throws SQLException { if (value != null) { ps.setString(parameterIdx, replaceNullChars(value)); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java index 3945c80c43..592ed21f9a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java @@ -24,6 +24,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.event.CalculatedFieldDebugEventFilter; import org.thingsboard.server.common.data.event.ErrorEventFilter; import org.thingsboard.server.common.data.event.Event; import org.thingsboard.server.common.data.event.EventFilter; @@ -72,6 +73,7 @@ public class JpaBaseEventDao implements EventDao { private final RuleChainDebugEventRepository ruleChainDebugEventRepository; private final ScheduledLogExecutorComponent logExecutor; private final StatsFactory statsFactory; + private final CalculatedFieldDebugEventRepository calculatedFieldDebugEventRepository; @Value("${sql.events.batch_size:10000}") private int batchSize; @@ -110,6 +112,7 @@ public class JpaBaseEventDao implements EventDao { repositories.put(EventType.ERROR, errorEventRepository); repositories.put(EventType.DEBUG_RULE_NODE, ruleNodeDebugEventRepository); repositories.put(EventType.DEBUG_RULE_CHAIN, ruleChainDebugEventRepository); + repositories.put(EventType.DEBUG_CALCULATED_FIELD, calculatedFieldDebugEventRepository); } @PreDestroy @@ -158,6 +161,8 @@ public class JpaBaseEventDao implements EventDao { return findEventByFilter(tenantId, entityId, (ErrorEventFilter) eventFilter, pageLink); case STATS: return findEventByFilter(tenantId, entityId, (StatisticsEventFilter) eventFilter, pageLink); + case DEBUG_CALCULATED_FIELD: + return findEventByFilter(tenantId, entityId, (CalculatedFieldDebugEventFilter) eventFilter, pageLink); default: throw new RuntimeException("Not supported event type: " + eventFilter.getEventType()); } @@ -193,6 +198,8 @@ public class JpaBaseEventDao implements EventDao { case STATS: removeEventsByFilter(tenantId, entityId, (StatisticsEventFilter) eventFilter, startTime, endTime); break; + case DEBUG_CALCULATED_FIELD: + removeEventsByFilter(tenantId, entityId, (CalculatedFieldDebugEventFilter) eventFilter, startTime, endTime); default: throw new RuntimeException("Not supported event type: " + eventFilter.getEventType()); } @@ -286,6 +293,28 @@ public class JpaBaseEventDao implements EventDao { ); } + private PageData findEventByFilter(UUID tenantId, UUID entityId, CalculatedFieldDebugEventFilter eventFilter, TimePageLink pageLink) { + parseUUID(eventFilter.getEntityId(), "Entity Id"); + parseUUID(eventFilter.getMsgId(), "Message Id"); + return DaoUtil.toPageData( + calculatedFieldDebugEventRepository.findEvents( + tenantId, + entityId, + pageLink.getStartTime(), + pageLink.getEndTime(), + eventFilter.getServer(), + entityId, + eventFilter.getEntityId(), + eventFilter.getEntityType(), + eventFilter.getMsgId(), + eventFilter.getMsgType(), + eventFilter.getArguments(), + eventFilter.getResult(), + eventFilter.isError(), + eventFilter.getErrorStr(), + DaoUtil.toPageable(pageLink, EventEntity.eventColumnMap))); + } + private void removeEventsByFilter(UUID tenantId, UUID entityId, RuleChainDebugEventFilter eventFilter, Long startTime, Long endTime) { ruleChainDebugEventRepository.removeEvents( tenantId, @@ -360,6 +389,26 @@ public class JpaBaseEventDao implements EventDao { ); } + private void removeEventsByFilter(UUID tenantId, UUID entityId, CalculatedFieldDebugEventFilter eventFilter, Long startTime, Long endTime) { + parseUUID(eventFilter.getEntityId(), "Entity Id"); + parseUUID(eventFilter.getMsgId(), "Message Id"); + calculatedFieldDebugEventRepository.removeEvents( + tenantId, + entityId, + startTime, + endTime, + eventFilter.getServer(), + entityId, + eventFilter.getEntityId(), + eventFilter.getEntityType(), + eventFilter.getMsgId(), + eventFilter.getMsgType(), + eventFilter.getArguments(), + eventFilter.getResult(), + eventFilter.isError(), + eventFilter.getErrorStr()); + } + @Override public List findLatestEvents(UUID tenantId, UUID entityId, EventType eventType, int limit) { return DaoUtil.convertDataList(getEventRepository(eventType).findLatestEvents(tenantId, entityId, limit)); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRuleDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRuleDao.java index 6781dda593..739423ee70 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRuleDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRuleDao.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.notification.rule.trigger.config.Notif import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; import org.thingsboard.server.dao.model.sql.NotificationRuleEntity; import org.thingsboard.server.dao.model.sql.NotificationRuleInfoEntity; import org.thingsboard.server.dao.notification.NotificationRuleDao; @@ -41,7 +42,7 @@ import java.util.UUID; @Component @SqlDao @RequiredArgsConstructor -public class JpaNotificationRuleDao extends JpaAbstractDao implements NotificationRuleDao { +public class JpaNotificationRuleDao extends JpaAbstractDao implements NotificationRuleDao, TenantEntityDao { private final NotificationRuleRepository notificationRuleRepository; @@ -101,6 +102,11 @@ public class JpaNotificationRuleDao extends JpaAbstractDao findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findByTenantId(tenantId.getId(), pageLink); + } + @Override protected Class getEntityClass() { return NotificationRuleEntity.class; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTargetDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTargetDao.java index 07799d2388..1efce2de01 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTargetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTargetDao.java @@ -101,6 +101,11 @@ public class JpaNotificationTargetDao extends JpaAbstractDao findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findByTenantId(tenantId.getId(), pageLink); + } + @Override protected Class getEntityClass() { return NotificationTargetEntity.class; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTemplateDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTemplateDao.java index 8941bf119d..0f7d6e1d64 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTemplateDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTemplateDao.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.notification.template.NotificationTemp import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; import org.thingsboard.server.dao.model.sql.NotificationTemplateEntity; import org.thingsboard.server.dao.notification.NotificationTemplateDao; import org.thingsboard.server.dao.sql.JpaAbstractDao; @@ -38,7 +39,7 @@ import java.util.UUID; @Component @SqlDao @RequiredArgsConstructor -public class JpaNotificationTemplateDao extends JpaAbstractDao implements NotificationTemplateDao { +public class JpaNotificationTemplateDao extends JpaAbstractDao implements NotificationTemplateDao, TenantEntityDao { private final NotificationTemplateRepository notificationTemplateRepository; @@ -83,6 +84,11 @@ public class JpaNotificationTemplateDao extends JpaAbstractDao findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findByTenantId(tenantId.getId(), pageLink); + } + @Override protected JpaRepository getRepository() { return notificationTemplateRepository; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageDao.java index 24ba852cd8..780f67932e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageDao.java @@ -19,9 +19,14 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.OtaPackage; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; import org.thingsboard.server.dao.model.sql.OtaPackageEntity; import org.thingsboard.server.dao.ota.OtaPackageDao; import org.thingsboard.server.dao.sql.JpaAbstractDao; @@ -32,7 +37,7 @@ import java.util.UUID; @Slf4j @Component @SqlDao -public class JpaOtaPackageDao extends JpaAbstractDao implements OtaPackageDao { +public class JpaOtaPackageDao extends JpaAbstractDao implements OtaPackageDao, TenantEntityDao { @Autowired private OtaPackageRepository otaPackageRepository; @@ -52,6 +57,12 @@ public class JpaOtaPackageDao extends JpaAbstractDao findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return DaoUtil.toPageData(otaPackageRepository.findByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink))); + } + @Override public EntityType getEntityType() { return EntityType.OTA_PACKAGE; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageInfoDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageInfoDao.java index dec6d36ec6..208b296d1c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageInfoDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageInfoDao.java @@ -93,4 +93,5 @@ public class JpaOtaPackageInfoDao extends JpaAbstractDao { + @Query(value = "SELECT COALESCE(SUM(ota.data_size), 0) FROM ota_package ota WHERE ota.tenant_id = :tenantId AND ota.data IS NOT NULL", nativeQuery = true) Long sumDataSizeByTenantId(@Param("tenantId") UUID tenantId); + + Page findByTenantId(UUID tenantId, Pageable pageable); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/AlarmQueryRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/AlarmQueryRepository.java index adbb3fc200..4380186786 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/AlarmQueryRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/AlarmQueryRepository.java @@ -30,6 +30,6 @@ public interface AlarmQueryRepository { PageData findAlarmDataByQueryForEntities(TenantId tenantId, AlarmDataQuery query, Collection orderedEntityIds); - long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query); + long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query, Collection orderedEntityIds); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultAlarmQueryRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultAlarmQueryRepository.java index 957e52142a..9e20a54b14 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultAlarmQueryRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultAlarmQueryRepository.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.permission.QueryContext; import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmDataPageLink; @@ -128,7 +129,7 @@ public class DefaultAlarmQueryRepository implements AlarmQueryRepository { public PageData findAlarmDataByQueryForEntities(TenantId tenantId, AlarmDataQuery query, Collection orderedEntityIds) { return transactionTemplate.execute(trStatus -> { AlarmDataPageLink pageLink = query.getPageLink(); - QueryContext ctx = new QueryContext(new QuerySecurityContext(tenantId, null, EntityType.ALARM)); + SqlQueryContext ctx = new SqlQueryContext(new QueryContext(tenantId, null, EntityType.ALARM)); ctx.addUuidListParameter("entity_ids", orderedEntityIds.stream().map(EntityId::getId).collect(Collectors.toList())); StringBuilder selectPart = new StringBuilder(FIELDS_SELECTION); StringBuilder fromPart = new StringBuilder(" from alarm_info a "); @@ -314,25 +315,41 @@ public class DefaultAlarmQueryRepository implements AlarmQueryRepository { } @Override - public long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query) { - QueryContext ctx = new QueryContext(new QuerySecurityContext(tenantId, null, EntityType.ALARM)); + public long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query, Collection orderedEntityIds) { + SqlQueryContext ctx = new SqlQueryContext(new QueryContext(tenantId, null, EntityType.ALARM)); if (query.isSearchPropagatedAlarms()) { ctx.append("select count(distinct(a.id)) from alarm_info a "); ctx.append(JOIN_ENTITY_ALARMS); - ctx.append("where a.tenant_id = :tenantId and ea.tenant_id = :tenantId"); - ctx.addUuidParameter("tenantId", tenantId.getId()); - if (customerId != null && !customerId.isNullUid()) { - ctx.append(" and a.customer_id = :customerId and ea.customer_id = :customerId"); - ctx.addUuidParameter("customerId", customerId.getId()); + if (orderedEntityIds != null) { + if (orderedEntityIds.isEmpty()) { + return 0; + } + ctx.addUuidListParameter("entity_filter_entity_ids", orderedEntityIds.stream().map(EntityId::getId).collect(Collectors.toList())); + ctx.append("where ea.entity_id in (:entity_filter_entity_ids)"); + } else { + ctx.append("where a.tenant_id = :tenantId and ea.tenant_id = :tenantId"); + ctx.addUuidParameter("tenantId", tenantId.getId()); + if (customerId != null && !customerId.isNullUid()) { + ctx.append(" and a.customer_id = :customerId and ea.customer_id = :customerId"); + ctx.addUuidParameter("customerId", customerId.getId()); + } } } else { ctx.append("select count(id) from alarm_info a "); - ctx.append("where a.tenant_id = :tenantId"); - ctx.addUuidParameter("tenantId", tenantId.getId()); - if (customerId != null && !customerId.isNullUid()) { - ctx.append(" and a.customer_id = :customerId"); - ctx.addUuidParameter("customerId", customerId.getId()); + if (orderedEntityIds != null) { + if (orderedEntityIds.isEmpty()) { + return 0; + } + ctx.addUuidListParameter("entity_filter_entity_ids", orderedEntityIds.stream().map(EntityId::getId).collect(Collectors.toList())); + ctx.append("where a.originator_id in (:entity_filter_entity_ids)"); + } else { + ctx.append("where a.tenant_id = :tenantId"); + ctx.addUuidParameter("tenantId", tenantId.getId()); + if (customerId != null && !customerId.isNullUid()) { + ctx.append(" and a.customer_id = :customerId"); + ctx.addUuidParameter("customerId", customerId.getId()); + } } } @@ -402,7 +419,7 @@ public class DefaultAlarmQueryRepository implements AlarmQueryRepository { }); } - private String buildTextSearchQuery(QueryContext ctx, List selectionMapping, String searchText) { + private String buildTextSearchQuery(SqlQueryContext ctx, List selectionMapping, String searchText) { if (!StringUtils.isEmpty(searchText) && selectionMapping != null && !selectionMapping.isEmpty()) { String lowerSearchText = searchText.toLowerCase() + "%"; List searchPredicates = selectionMapping.stream() @@ -420,7 +437,7 @@ public class DefaultAlarmQueryRepository implements AlarmQueryRepository { } } - private String buildPermissionsQuery(TenantId tenantId, QueryContext ctx) { + private String buildPermissionsQuery(TenantId tenantId, SqlQueryContext ctx) { StringBuilder permissionsQuery = new StringBuilder(); ctx.addUuidParameter("permissions_tenant_id", tenantId.getId()); permissionsQuery.append(" a.tenant_id = :permissions_tenant_id and ea.tenant_id = :permissions_tenant_id "); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java index ef712fa286..8f3fe71f35 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.permission.QueryContext; import org.thingsboard.server.common.data.query.ApiUsageStateFilter; import org.thingsboard.server.common.data.query.AssetSearchQueryFilter; import org.thingsboard.server.common.data.query.AssetTypeFilter; @@ -334,7 +335,7 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { @Override public long countEntitiesByQuery(TenantId tenantId, CustomerId customerId, EntityCountQuery query) { EntityType entityType = resolveEntityType(query.getEntityFilter()); - QueryContext ctx = new QueryContext(new QuerySecurityContext(tenantId, customerId, entityType, TenantId.SYS_TENANT_ID.equals(tenantId))); + SqlQueryContext ctx = new SqlQueryContext(new QueryContext(tenantId, customerId, entityType, TenantId.SYS_TENANT_ID.equals(tenantId))); if (query.getKeyFilters() == null || query.getKeyFilters().isEmpty()) { ctx.append("select count(e.id) from "); ctx.append(addEntityTableQuery(ctx, query.getEntityFilter())); @@ -416,7 +417,7 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { public PageData findEntityDataByQuery(TenantId tenantId, CustomerId customerId, EntityDataQuery query, boolean ignorePermissionCheck) { return transactionTemplate.execute(status -> { EntityType entityType = resolveEntityType(query.getEntityFilter()); - QueryContext ctx = new QueryContext(new QuerySecurityContext(tenantId, customerId, entityType, ignorePermissionCheck)); + SqlQueryContext ctx = new SqlQueryContext(new QueryContext(tenantId, customerId, entityType, ignorePermissionCheck)); EntityDataPageLink pageLink = query.getPageLink(); List mappings = EntityKeyMapping.prepareKeyMapping(entityType, query); @@ -524,7 +525,7 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { }); } - private String buildEntityWhere(QueryContext ctx, EntityFilter entityFilter, List entityFieldsFilters) { + private String buildEntityWhere(SqlQueryContext ctx, EntityFilter entityFilter, List entityFieldsFilters) { String permissionQuery = this.buildPermissionQuery(ctx, entityFilter); String entityFilterQuery = this.buildEntityFilterQuery(ctx, entityFilter); String entityFieldsQuery = EntityKeyMapping.buildQuery(ctx, entityFieldsFilters, entityFilter.getType()); @@ -538,7 +539,7 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { return result; } - private String buildPermissionQuery(QueryContext ctx, EntityFilter entityFilter) { + private String buildPermissionQuery(SqlQueryContext ctx, EntityFilter entityFilter) { if (ctx.isIgnorePermissionCheck()) { return "1=1"; } @@ -575,7 +576,7 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { } } - private String defaultPermissionQuery(QueryContext ctx) { + private String defaultPermissionQuery(SqlQueryContext ctx) { ctx.addUuidParameter("permissions_tenant_id", ctx.getTenantId().getId()); if (ctx.getCustomerId() != null && !ctx.getCustomerId().isNullUid()) { ctx.addUuidParameter("permissions_customer_id", ctx.getCustomerId().getId()); @@ -593,7 +594,7 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { } } - private String buildEntityFilterQuery(QueryContext ctx, EntityFilter entityFilter) { + private String buildEntityFilterQuery(SqlQueryContext ctx, EntityFilter entityFilter) { switch (entityFilter.getType()) { case SINGLE_ENTITY: return this.singleEntityQuery(ctx, (SingleEntityFilter) entityFilter); @@ -619,7 +620,7 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { } } - private String addEntityTableQuery(QueryContext ctx, EntityFilter entityFilter) { + private String addEntityTableQuery(SqlQueryContext ctx, EntityFilter entityFilter) { switch (entityFilter.getType()) { case RELATIONS_QUERY: return relationQuery(ctx, (RelationsQueryFilter) entityFilter); @@ -640,7 +641,7 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { } } - private String entitySearchQuery(QueryContext ctx, EntitySearchQueryFilter entityFilter, EntityType entityType, List types) { + private String entitySearchQuery(SqlQueryContext ctx, EntitySearchQueryFilter entityFilter, EntityType entityType, List types) { EntityId rootId = entityFilter.getRootEntity(); String lvlFilter = getLvlFilter(entityFilter.getMaxLevel()); String selectFields = "SELECT tenant_id, customer_id, id, created_time, type, name, additional_info " @@ -680,7 +681,7 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { return query; } - private String relationQuery(QueryContext ctx, RelationsQueryFilter entityFilter) { + private String relationQuery(SqlQueryContext ctx, RelationsQueryFilter entityFilter) { EntityId rootId = entityFilter.getRootEntity(); String lvlFilter = getLvlFilter(entityFilter.getMaxLevel()); String selectFields = SELECT_TENANT_ID + ", " + SELECT_CUSTOMER_ID @@ -692,6 +693,10 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { SELECT_ADDRESS + ", " + SELECT_ADDRESS_2 + ", " + SELECT_ZIP + ", " + SELECT_PHONE + ", " + SELECT_ADDITIONAL_INFO + (entityFilter.isMultiRoot() ? (", " + SELECT_RELATED_PARENT_ID) : "") + ", entity.entity_type as entity_type"; + /* + * FIXME: + * target entities are duplicated in result list, if search direction is TO and multiple relations are references to target entity + * */ String from = getQueryTemplate(entityFilter.getDirection(), entityFilter.isMultiRoot()); if (entityFilter.isMultiRoot()) { @@ -763,7 +768,7 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { return "( " + selectFields + from + ")"; } - private String buildEtfCondition(QueryContext ctx, RelationEntityTypeFilter etf, EntitySearchDirection direction, int entityTypeFilterIdx) { + private String buildEtfCondition(SqlQueryContext ctx, RelationEntityTypeFilter etf, EntitySearchDirection direction, int entityTypeFilterIdx) { StringBuilder whereFilter = new StringBuilder(); String relationType = etf.getRelationType(); List entityTypes = etf.getEntityTypes(); @@ -812,7 +817,7 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { return from; } - private String buildAliasWhereQuery(QueryContext ctx, EntityFilter entityFilter, List selectionMapping, String searchText) { + private String buildAliasWhereQuery(SqlQueryContext ctx, EntityFilter entityFilter, List selectionMapping, String searchText) { List aliasFiltersMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest() && mapping.getEntityKeyColumn() == null) .collect(Collectors.toList()); String entityFieldsQuery = EntityKeyMapping.buildQuery(ctx, aliasFiltersMapping, entityFilter.getType()); @@ -822,12 +827,12 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { result += " where (" + entityFieldsQuery + ")"; } if (!searchTextQuery.isEmpty()) { - result += (result.isEmpty() ? " where ": " and ") + "(" + searchTextQuery + ") "; + result += (result.isEmpty() ? " where " : " and ") + "(" + searchTextQuery + ") "; } return result; } - private String buildTextSearchQuery(QueryContext ctx, List selectionMapping, String searchText) { + private String buildTextSearchQuery(SqlQueryContext ctx, List selectionMapping, String searchText) { if (!StringUtils.isEmpty(searchText) && !selectionMapping.isEmpty()) { String sqlSearchText = "%" + searchText + "%"; ctx.addStringParameter("lowerSearchTextParam", sqlSearchText); @@ -844,17 +849,17 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { } } - private String singleEntityQuery(QueryContext ctx, SingleEntityFilter filter) { + private String singleEntityQuery(SqlQueryContext ctx, SingleEntityFilter filter) { ctx.addUuidParameter("entity_filter_single_entity_id", filter.getSingleEntity().getId()); return "e.id=:entity_filter_single_entity_id"; } - private String entityListQuery(QueryContext ctx, EntityListFilter filter) { + private String entityListQuery(SqlQueryContext ctx, EntityListFilter filter) { ctx.addUuidListParameter("entity_filter_entity_ids", filter.getEntityList().stream().map(UUID::fromString).collect(Collectors.toList())); return "e.id in (:entity_filter_entity_ids)"; } - private String entityNameQuery(QueryContext ctx, EntityNameFilter filter) { + private String entityNameQuery(SqlQueryContext ctx, EntityNameFilter filter) { ctx.addStringParameter("entity_filter_name_filter", filter.getEntityNameFilter()); String nameColumn = getNameColumn(filter.getEntityType()); if (filter.getEntityNameFilter().startsWith("%") || filter.getEntityNameFilter().endsWith("%")) { @@ -864,7 +869,7 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { return String.format("e.%s ilike concat(:entity_filter_name_filter, '%%')", nameColumn); } - private String typeQuery(QueryContext ctx, EntityFilter filter) { + private String typeQuery(SqlQueryContext ctx, EntityFilter filter) { List types; String name; String nameColumn; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultQueryLogComponent.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultQueryLogComponent.java index 64e1f84db9..f6ca1581d5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultQueryLogComponent.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultQueryLogComponent.java @@ -37,7 +37,7 @@ public class DefaultQueryLogComponent implements QueryLogComponent { private long logQueriesThreshold; @Override - public void logQuery(QueryContext ctx, String query, long duration) { + public void logQuery(SqlQueryContext ctx, String query, long duration) { if (logSqlQueries && duration > logQueriesThreshold) { String sqlToUse = substituteParametersInSqlString(query, ctx); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DummyEdqsApiService.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DummyEdqsApiService.java new file mode 100644 index 0000000000..e486d3b645 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DummyEdqsApiService.java @@ -0,0 +1,58 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.sql.query; + +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.edqs.query.EdqsRequest; +import org.thingsboard.server.common.data.edqs.query.EdqsResponse; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.edqs.EdqsApiService; + +@Service +@Slf4j +@ConditionalOnMissingBean(value = EdqsApiService.class, ignored = DummyEdqsApiService.class) +public class DummyEdqsApiService implements EdqsApiService { + + @Override + public ListenableFuture processRequest(TenantId tenantId, CustomerId customerId, EdqsRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isEnabled() { + return false; + } + + @Override + public void setEnabled(boolean enabled) { + log.warn("Got request to enable EDQS API, but it isn't supported", new RuntimeException("stacktrace")); + } + + @Override + public boolean isSupported() { + return false; + } + + @Override + public boolean isAutoEnable() { + return false; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DummyEdqsService.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DummyEdqsService.java new file mode 100644 index 0000000000..514e07c323 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DummyEdqsService.java @@ -0,0 +1,50 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.sql.query; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.edqs.EdqsObject; +import org.thingsboard.server.common.data.edqs.ToCoreEdqsMsg; +import org.thingsboard.server.common.data.edqs.ToCoreEdqsRequest; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.edqs.EdqsService; + +@Service +@ConditionalOnMissingBean(value = EdqsService.class, ignored = DummyEdqsService.class) +public class DummyEdqsService implements EdqsService { + + @Override + public void onUpdate(TenantId tenantId, EntityId entityId, Object entity) {} + + @Override + public void onUpdate(TenantId tenantId, ObjectType objectType, EdqsObject object) {} + + @Override + public void onDelete(TenantId tenantId, EntityId entityId) {} + + @Override + public void onDelete(TenantId tenantId, ObjectType objectType, EdqsObject object) {} + + @Override + public void processSystemRequest(ToCoreEdqsRequest request) {} + + @Override + public void processSystemMsg(ToCoreEdqsMsg request) {} + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java index 949941a3b6..9755201fe9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java @@ -103,7 +103,7 @@ public class EntityKeyMapping { public static final List labeledEntityFields = Arrays.asList(CREATED_TIME, ENTITY_TYPE, NAME, TYPE, LABEL, ADDITIONAL_INFO); public static final List contactBasedEntityFields = Arrays.asList(CREATED_TIME, ENTITY_TYPE, EMAIL, TITLE, COUNTRY, STATE, CITY, ADDRESS, ADDRESS_2, ZIP, PHONE, ADDITIONAL_INFO); - public static final Set apiUsageStateEntityFields = new HashSet<>(Arrays.asList(CREATED_TIME, ENTITY_TYPE, NAME)); + public static final Set apiUsageStateEntityFields = new HashSet<>(Arrays.asList(CREATED_TIME, ENTITY_TYPE, NAME)); public static final Set commonEntityFieldsSet = new HashSet<>(commonEntityFields); public static final Set relationQueryEntityFieldsSet = new HashSet<>(Arrays.asList(CREATED_TIME, ENTITY_TYPE, NAME, TYPE, LABEL, FIRST_NAME, LAST_NAME, EMAIL, REGION, TITLE, COUNTRY, STATE, CITY, ADDRESS, ADDRESS_2, ZIP, PHONE, ADDITIONAL_INFO, RELATED_PARENT_ID)); @@ -265,7 +265,7 @@ public class EntityKeyMapping { return alias; } - public Stream toQueries(QueryContext ctx, EntityFilterType filterType) { + public Stream toQueries(SqlQueryContext ctx, EntityFilterType filterType) { if (hasFilter()) { String keyAlias = (entityKey.getType().equals(EntityKeyType.ENTITY_FIELD) && getEntityKeyColumn() != null) ? "e" : alias; return keyFilters.stream().map(keyFilter -> @@ -275,7 +275,7 @@ public class EntityKeyMapping { } } - public String toLatestJoin(QueryContext ctx, EntityFilter entityFilter, EntityType entityType) { + public String toLatestJoin(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType) { String entityTypeStr; if (entityFilter.getType().equals(EntityFilterType.RELATIONS_QUERY)) { entityTypeStr = "entities.entity_type"; @@ -303,9 +303,9 @@ public class EntityKeyMapping { if (entityKey.getType().equals(EntityKeyType.CLIENT_ATTRIBUTE)) { scope = AttributeScope.CLIENT_SCOPE.getId(); } else if (entityKey.getType().equals(EntityKeyType.SHARED_ATTRIBUTE)) { - scope = AttributeScope.SHARED_SCOPE.getId();; + scope = AttributeScope.SHARED_SCOPE.getId(); ; } else { - scope = AttributeScope.SERVER_SCOPE.getId();; + scope = AttributeScope.SERVER_SCOPE.getId(); ; } query = String.format("%s AND %s.attribute_type=%s %s", query, alias, scope, filterQuery); } else { @@ -318,7 +318,7 @@ public class EntityKeyMapping { } } - private boolean hasFilterValues(QueryContext ctx) { + private boolean hasFilterValues(SqlQueryContext ctx) { return Arrays.stream(ctx.getParameterNames()).anyMatch(parameterName -> { return !parameterName.equals(getKeyId()) && parameterName.startsWith(alias); }); @@ -333,14 +333,14 @@ public class EntityKeyMapping { Collectors.joining(", ")); } - public static String buildLatestJoins(QueryContext ctx, EntityFilter entityFilter, EntityType entityType, List latestMappings, boolean countQuery) { + public static String buildLatestJoins(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType, List latestMappings, boolean countQuery) { return latestMappings.stream() .filter(mapping -> !countQuery || mapping.hasFilter()) .map(mapping -> mapping.toLatestJoin(ctx, entityFilter, entityType)) .collect(Collectors.joining(" ")); } - public static String buildQuery(QueryContext ctx, List mappings, EntityFilterType filterType) { + public static String buildQuery(SqlQueryContext ctx, List mappings, EntityFilterType filterType) { return mappings.stream() .flatMap(mapping -> mapping.toQueries(ctx, filterType)) .filter(StringUtils::isNotEmpty) @@ -510,12 +510,12 @@ public class EntityKeyMapping { return getValueAlias() + "_so_num"; } - private String buildKeyQuery(QueryContext ctx, String alias, KeyFilter keyFilter, + private String buildKeyQuery(SqlQueryContext ctx, String alias, KeyFilter keyFilter, EntityFilterType filterType) { return this.buildPredicateQuery(ctx, alias, keyFilter.getKey(), keyFilter.getPredicate(), filterType); } - private String buildPredicateQuery(QueryContext ctx, String alias, EntityKey key, + private String buildPredicateQuery(SqlQueryContext ctx, String alias, EntityKey key, KeyFilterPredicate predicate, EntityFilterType filterType) { if (predicate.getType().equals(FilterPredicateType.COMPLEX)) { return this.buildComplexPredicateQuery(ctx, alias, key, (ComplexFilterPredicate) predicate, filterType); @@ -524,7 +524,7 @@ public class EntityKeyMapping { } } - private String buildComplexPredicateQuery(QueryContext ctx, String alias, EntityKey key, + private String buildComplexPredicateQuery(SqlQueryContext ctx, String alias, EntityKey key, ComplexFilterPredicate predicate, EntityFilterType filterType) { String result = predicate.getPredicates().stream() .map(keyFilterPredicate -> this.buildPredicateQuery(ctx, alias, key, keyFilterPredicate, filterType)) @@ -536,7 +536,7 @@ public class EntityKeyMapping { return result; } - private String buildSimplePredicateQuery(QueryContext ctx, String alias, EntityKey key, + private String buildSimplePredicateQuery(SqlQueryContext ctx, String alias, EntityKey key, KeyFilterPredicate predicate, EntityFilterType filterType) { if (key.getType().equals(EntityKeyType.ENTITY_FIELD)) { String field = (getEntityKeyColumn() != null) ? alias + "." + getEntityKeyColumn() : alias; @@ -571,7 +571,7 @@ public class EntityKeyMapping { } } - private String buildStringPredicateQuery(QueryContext ctx, String field, StringFilterPredicate stringFilterPredicate) { + private String buildStringPredicateQuery(SqlQueryContext ctx, String field, StringFilterPredicate stringFilterPredicate) { String operationField = field; String paramName = getNextParameterName(field); String value = stringFilterPredicate.getValue().getValue(); @@ -624,7 +624,7 @@ public class EntityKeyMapping { return String.format("((%s is not null and %s)", field, stringOperationQuery); } - private String buildNumericPredicateQuery(QueryContext ctx, String field, NumericFilterPredicate numericFilterPredicate) { + private String buildNumericPredicateQuery(SqlQueryContext ctx, String field, NumericFilterPredicate numericFilterPredicate) { String paramName = getNextParameterName(field); ctx.addDoubleParameter(paramName, numericFilterPredicate.getValue().getValue()); String numericOperationQuery = ""; @@ -651,7 +651,7 @@ public class EntityKeyMapping { return String.format("(%s is not null and %s)", field, numericOperationQuery); } - private String buildBooleanPredicateQuery(QueryContext ctx, String field, + private String buildBooleanPredicateQuery(SqlQueryContext ctx, String field, BooleanFilterPredicate booleanFilterPredicate) { String paramName = getNextParameterName(field); ctx.addBooleanParameter(paramName, booleanFilterPredicate.getValue().getValue()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/QueryLogComponent.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/QueryLogComponent.java index ea15421fb7..86daeea77d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/QueryLogComponent.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/QueryLogComponent.java @@ -17,5 +17,5 @@ package org.thingsboard.server.dao.sql.query; public interface QueryLogComponent { - void logQuery(QueryContext ctx, String query, long duration); + void logQuery(SqlQueryContext ctx, String query, long duration); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/QueryContext.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/SqlQueryContext.java similarity index 94% rename from dao/src/main/java/org/thingsboard/server/dao/sql/query/QueryContext.java rename to dao/src/main/java/org/thingsboard/server/dao/sql/query/SqlQueryContext.java index 0e33c1b44f..625458342c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/QueryContext.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/SqlQueryContext.java @@ -21,6 +21,7 @@ import org.springframework.jdbc.core.namedparam.SqlParameterSource; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.permission.QueryContext; import java.sql.Types; import java.util.HashMap; @@ -29,14 +30,14 @@ import java.util.Map; import java.util.UUID; @Slf4j -public class QueryContext implements SqlParameterSource { +public class SqlQueryContext implements SqlParameterSource { private static final UUIDJdbcType UUID_TYPE = UUIDJdbcType.INSTANCE; - private final QuerySecurityContext securityCtx; + private final QueryContext securityCtx; private final StringBuilder query; private final Map params; - public QueryContext(QuerySecurityContext securityCtx) { + public SqlQueryContext(QueryContext securityCtx) { this.securityCtx = securityCtx; query = new StringBuilder(); params = new HashMap<>(); @@ -48,7 +49,7 @@ public class QueryContext implements SqlParameterSource { if (oldParam != null && oldParam.value != null && !oldParam.value.equals(newParam.value)) { throw new RuntimeException("Parameter with name: " + name + " was already registered!"); } - if(value == null){ + if (value == null) { log.warn("[{}][{}][{}] Trying to set null value", getTenantId(), getCustomerId(), name); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/queue/JpaQueueDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/queue/JpaQueueDao.java index dcfd008367..566e4eaddc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/queue/JpaQueueDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/queue/JpaQueueDao.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.queue.Queue; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; import org.thingsboard.server.dao.model.sql.QueueEntity; import org.thingsboard.server.dao.queue.QueueDao; import org.thingsboard.server.dao.sql.JpaAbstractDao; @@ -38,7 +39,7 @@ import java.util.UUID; @Slf4j @Component @SqlDao -public class JpaQueueDao extends JpaAbstractDao implements QueueDao { +public class JpaQueueDao extends JpaAbstractDao implements QueueDao, TenantEntityDao { @Autowired private QueueRepository queueRepository; @@ -87,6 +88,11 @@ public class JpaQueueDao extends JpaAbstractDao implements Q .findByTenantId(tenantId.getId(), pageLink.getTextSearch(), DaoUtil.toPageable(pageLink))); } + @Override + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findQueuesByTenantId(tenantId, pageLink); + } + @Override public EntityType getEntityType() { return EntityType.QUEUE; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/queue/JpaQueueStatsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/queue/JpaQueueStatsDao.java index 15428dfa63..e09d01503c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/queue/JpaQueueStatsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/queue/JpaQueueStatsDao.java @@ -17,9 +17,11 @@ package org.thingsboard.server.dao.sql.queue; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.fields.QueueStatsFields; import org.thingsboard.server.common.data.id.QueueStatsId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -60,7 +62,7 @@ public class JpaQueueStatsDao extends JpaAbstractDao findByTenantId(TenantId tenantId, PageLink pageLink) { + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { return DaoUtil.toPageData(queueStatsRepository.findByTenantId(tenantId.getId(), pageLink.getTextSearch(), DaoUtil.toPageable(pageLink))); } @@ -74,6 +76,11 @@ public class JpaQueueStatsDao extends JpaAbstractDao findNextBatch(UUID id, int batchSize) { + return queueStatsRepository.findNextBatch(id, Limit.of(batchSize)); + } + @Override public EntityType getEntityType() { return EntityType.QUEUE_STATS; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/queue/QueueStatsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/queue/QueueStatsRepository.java index bff8f05658..38e6fa9977 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/queue/QueueStatsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/queue/QueueStatsRepository.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.sql.queue; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -22,6 +23,7 @@ import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.common.data.edqs.fields.QueueStatsFields; import org.thingsboard.server.dao.model.sql.QueueStatsEntity; import java.util.List; @@ -45,4 +47,8 @@ public interface QueueStatsRepository extends JpaRepository findByTenantIdAndIdIn(UUID tenantId, List queueStatsIds); + @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.QueueStatsFields(q.id, q.createdTime," + + "q.tenantId, q.queueName, q.serviceId) FROM QueueStatsEntity q WHERE q.id > :id ORDER BY q.id") + List findNextBatch(@Param("id") UUID id, Limit limit); + } \ No newline at end of file diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java index 97e82b2021..7417418f54 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java @@ -127,6 +127,7 @@ public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService imple typeGroup.name())); } + @Override public ListenableFuture checkRelationAsync(TenantId tenantId, EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup) { return service.submit(() -> checkRelation(tenantId, from, to, relationType, typeGroup)); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationRepository.java index cebd95f08e..b4e8a21372 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationRepository.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.sql.relation; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; @@ -84,4 +85,15 @@ public interface RelationRepository @Query("DELETE FROM RelationEntity r where r.fromId = :fromId and r.fromType = :fromType and r.relationTypeGroup in :relationTypeGroups") void deleteByFromIdAndFromTypeAndRelationTypeGroupIn(@Param("fromId") UUID fromId, @Param("fromType") String fromType, @Param("relationTypeGroups") List relationTypeGroups); + @Query(value = "SELECT from_id, from_type, relation_type_group, relation_type, to_id, to_type, additional_info, version FROM relation" + + " WHERE (from_id, from_type, relation_type_group, relation_type, to_id, to_type) > " + + "(:fromId, :fromType, :relationTypeGroup, :relationType, :toId, :toType) ORDER BY " + + "from_id, from_type, relation_type_group, relation_type, to_id, to_type LIMIT :batchSize", nativeQuery = true) + List findNextBatch(@Param("fromId") UUID fromId, + @Param("fromType") String fromType, + @Param("relationTypeGroup") String relationTypeGroup, + @Param("relationType") String relationType, + @Param("toId") UUID toId, + @Param("toType") String toType, + @Param("batchSize") int batchSize); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java index 15517b4e3f..6cce9d76c2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; import org.thingsboard.server.dao.model.sql.TbResourceEntity; import org.thingsboard.server.dao.resource.TbResourceDao; import org.thingsboard.server.dao.sql.JpaAbstractDao; @@ -38,7 +39,7 @@ import java.util.UUID; @Slf4j @Component @SqlDao -public class JpaTbResourceDao extends JpaAbstractDao implements TbResourceDao { +public class JpaTbResourceDao extends JpaAbstractDao implements TbResourceDao, TenantEntityDao { private final TbResourceRepository resourceRepository; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/rpc/JpaRpcDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/rpc/JpaRpcDao.java index ef7d28bb63..73e251b6db 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/rpc/JpaRpcDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/rpc/JpaRpcDao.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.rpc.Rpc; import org.thingsboard.server.common.data.rpc.RpcStatus; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; import org.thingsboard.server.dao.model.sql.RpcEntity; import org.thingsboard.server.dao.rpc.RpcDao; import org.thingsboard.server.dao.sql.JpaAbstractDao; @@ -39,7 +40,7 @@ import java.util.UUID; @Component @AllArgsConstructor @SqlDao -public class JpaRpcDao extends JpaAbstractDao implements RpcDao { +public class JpaRpcDao extends JpaAbstractDao implements RpcDao, TenantEntityDao { private final RpcRepository rpcRepository; @@ -74,6 +75,11 @@ public class JpaRpcDao extends JpaAbstractDao implements RpcDao return rpcRepository.deleteOutdatedRpcByTenantId(tenantId.getId(), expirationTime); } + @Override + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findAllRpcByTenantId(tenantId, pageLink); + } + @Override public EntityType getEntityType() { return EntityType.RPC; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java index 3d025fc469..77044d41dc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java @@ -17,9 +17,11 @@ package org.thingsboard.server.dao.sql.rule; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.fields.RuleChainFields; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -33,6 +35,7 @@ import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.util.SqlDao; import java.util.Collection; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -133,6 +136,16 @@ public class JpaRuleChainDao extends JpaAbstractDao return findRootRuleChainByTenantIdAndType(tenantId, RuleChainType.CORE); } + @Override + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findRuleChainsByTenantId(tenantId.getId(), pageLink); + } + + @Override + public List findNextBatch(UUID id, int batchSize) { + return ruleChainRepository.findNextBatch(id, Limit.of(batchSize)); + } + @Override public EntityType getEntityType() { return EntityType.RULE_CHAIN; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDao.java index c3a8b447c5..8ecce0672f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDao.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; import org.thingsboard.server.dao.model.sql.RuleNodeEntity; import org.thingsboard.server.dao.rule.RuleNodeDao; import org.thingsboard.server.dao.sql.JpaAbstractDao; @@ -40,7 +41,7 @@ import java.util.stream.Collectors; @Slf4j @Component @SqlDao -public class JpaRuleNodeDao extends JpaAbstractDao implements RuleNodeDao { +public class JpaRuleNodeDao extends JpaAbstractDao implements RuleNodeDao, TenantEntityDao { @Autowired private RuleNodeRepository ruleNodeRepository; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java index 01bec2a846..cfa06caf14 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.dao.sql.rule; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.edqs.fields.RuleChainFields; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.RuleChainEntity; @@ -70,4 +72,7 @@ public interface RuleChainRepository extends JpaRepository :id ORDER BY r.id") + List findNextBatch(@Param("id") UUID id, Limit limit); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/settings/AdminSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/settings/AdminSettingsRepository.java index bd9b20e01e..c27602421d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/settings/AdminSettingsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/settings/AdminSettingsRepository.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.dao.sql.settings; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.thingsboard.server.dao.model.sql.AdminSettingsEntity; @@ -33,4 +35,6 @@ public interface AdminSettingsRepository extends JpaRepository findByTenantId(UUID tenantId, Pageable pageable); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java index 94f39d223c..68ce5e9d22 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java @@ -21,7 +21,11 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; import org.thingsboard.server.dao.model.sql.AdminSettingsEntity; import org.thingsboard.server.dao.settings.AdminSettingsDao; import org.thingsboard.server.dao.sql.JpaAbstractDao; @@ -32,7 +36,7 @@ import java.util.UUID; @Component @SqlDao @Slf4j -public class JpaAdminSettingsDao extends JpaAbstractDao implements AdminSettingsDao { +public class JpaAdminSettingsDao extends JpaAbstractDao implements AdminSettingsDao, TenantEntityDao { @Autowired private AdminSettingsRepository adminSettingsRepository; @@ -68,4 +72,9 @@ public class JpaAdminSettingsDao extends JpaAbstractDao findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return DaoUtil.toPageData(adminSettingsRepository.findByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink))); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/JpaTenantDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/JpaTenantDao.java index 0ba805f6e1..d031dbb8ee 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/JpaTenantDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/JpaTenantDao.java @@ -16,11 +16,13 @@ package org.thingsboard.server.dao.sql.tenant; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantInfo; +import org.thingsboard.server.common.data.edqs.fields.TenantFields; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.page.PageData; @@ -94,4 +96,9 @@ public class JpaTenantDao extends JpaAbstractDao implement .map(TenantId::fromUUID) .collect(Collectors.toList()); } + + @Override + public List findNextBatch(UUID id, int batchSize) { + return tenantRepository.findNextBatch(id, Limit.of(batchSize)); + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/JpaTenantProfileDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/JpaTenantProfileDao.java index acc5feef31..839d62c48d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/JpaTenantProfileDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/JpaTenantProfileDao.java @@ -16,11 +16,13 @@ package org.thingsboard.server.dao.sql.tenant; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.edqs.fields.TenantProfileFields; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -87,6 +89,11 @@ public class JpaTenantProfileDao extends JpaAbstractDao findNextBatch(UUID id, int batchSize) { + return tenantProfileRepository.findNextBatch(id, Limit.of(batchSize)); + } + @Override public EntityType getEntityType() { return EntityType.TENANT_PROFILE; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/TenantProfileRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/TenantProfileRepository.java index dc918c900e..c5759c8a0f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/TenantProfileRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/TenantProfileRepository.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.dao.sql.tenant; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.thingsboard.server.common.data.EntityInfo; +import org.thingsboard.server.common.data.edqs.fields.TenantProfileFields; import org.thingsboard.server.dao.model.sql.TenantProfileEntity; import java.util.List; @@ -55,4 +57,8 @@ public interface TenantProfileRepository extends JpaRepository findByIdIn(List ids); + @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.TenantProfileFields(t.id, t.createdTime, t.name," + + "t.isDefault) FROM TenantProfileEntity t WHERE t.id > :id ORDER BY t.id") + List findNextBatch(@Param("id") UUID id, Limit limit); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/TenantRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/TenantRepository.java index 8adb4e0261..bafa9a6fe6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/TenantRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/tenant/TenantRepository.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.dao.sql.tenant; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.edqs.fields.TenantFields; import org.thingsboard.server.dao.model.sql.TenantEntity; import org.thingsboard.server.dao.model.sql.TenantInfoEntity; @@ -53,4 +55,8 @@ public interface TenantRepository extends JpaRepository { @Query("SELECT t.id FROM TenantEntity t where t.tenantProfileId = :tenantProfileId") List findTenantIdsByTenantProfileId(@Param("tenantProfileId") UUID tenantProfileId); + + @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.TenantFields(t.id, t.createdTime, t.title, t.version," + + "t.additionalInfo, t.country, t.state, t.city, t.address, t.address2, t.zip, t.phone, t.email, t.region) FROM TenantEntity t WHERE t.id > :id ORDER BY t.id") + List findNextBatch(@Param("id") UUID id, Limit limit); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/usagerecord/ApiUsageStateRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/usagerecord/ApiUsageStateRepository.java index 0b27632f7a..98e62fc110 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/usagerecord/ApiUsageStateRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/usagerecord/ApiUsageStateRepository.java @@ -15,13 +15,18 @@ */ package org.thingsboard.server.dao.sql.usagerecord; +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.common.data.edqs.fields.ApiUsageStateFields; import org.thingsboard.server.dao.model.sql.ApiUsageStateEntity; +import java.util.List; import java.util.UUID; /** @@ -35,6 +40,8 @@ public interface ApiUsageStateRepository extends JpaRepository findAllByTenantId(UUID tenantId, Pageable pageable); + @Transactional @Modifying @Query("DELETE FROM ApiUsageStateEntity ur WHERE ur.tenantId = :tenantId") @@ -44,4 +51,10 @@ public interface ApiUsageStateRepository extends JpaRepository :id ORDER BY a.id") + List findNextBatch(@Param("id") UUID id, Limit limit); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/usagerecord/JpaApiUsageStateDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/usagerecord/JpaApiUsageStateDao.java index 6c0ac91507..ec68fa34c3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/usagerecord/JpaApiUsageStateDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/usagerecord/JpaApiUsageStateDao.java @@ -15,18 +15,23 @@ */ package org.thingsboard.server.dao.sql.usagerecord; +import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.fields.ApiUsageStateFields; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.model.sql.ApiUsageStateEntity; import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.usagerecord.ApiUsageStateDao; import org.thingsboard.server.dao.util.SqlDao; +import java.util.List; import java.util.UUID; /** @@ -72,6 +77,16 @@ public class JpaApiUsageStateDao extends JpaAbstractDao findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return DaoUtil.toPageData(apiUsageStateRepository.findAllByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink))); + } + + @Override + public List findNextBatch(UUID id, int batchSize) { + return apiUsageStateRepository.findNextBatch(id, Limit.of(batchSize)); + } + @Override public EntityType getEntityType() { return EntityType.API_USAGE_STATE; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserAuthSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserAuthSettingsDao.java index d68dd395bd..4f9ff5d222 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserAuthSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserAuthSettingsDao.java @@ -21,6 +21,7 @@ import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.UserAuthSettings; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; import org.thingsboard.server.dao.model.sql.UserAuthSettingsEntity; import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.user.UserAuthSettingsDao; @@ -31,7 +32,7 @@ import java.util.UUID; @Component @RequiredArgsConstructor @SqlDao -public class JpaUserAuthSettingsDao extends JpaAbstractDao implements UserAuthSettingsDao { +public class JpaUserAuthSettingsDao extends JpaAbstractDao implements UserAuthSettingsDao, TenantEntityDao { private final UserAuthSettingsRepository repository; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserCredentialsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserCredentialsDao.java index 30b643afa5..bcae1dcdf4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserCredentialsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserCredentialsDao.java @@ -20,8 +20,11 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; import org.thingsboard.server.dao.model.sql.UserCredentialsEntity; import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.user.UserCredentialsDao; @@ -34,7 +37,7 @@ import java.util.UUID; */ @Component @SqlDao -public class JpaUserCredentialsDao extends JpaAbstractDao implements UserCredentialsDao { +public class JpaUserCredentialsDao extends JpaAbstractDao implements UserCredentialsDao, TenantEntityDao { @Autowired private UserCredentialsRepository userCredentialsRepository; @@ -84,4 +87,9 @@ public class JpaUserCredentialsDao extends JpaAbstractDao findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return DaoUtil.toPageData(userCredentialsRepository.findByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink))); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserDao.java index c0e5b530ef..35d15bab51 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserDao.java @@ -16,10 +16,12 @@ package org.thingsboard.server.dao.sql.user; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.edqs.fields.UserFields; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantProfileId; @@ -139,6 +141,16 @@ public class JpaUserDao extends JpaAbstractDao implements User return userRepository.countByTenantId(tenantId.getId()); } + @Override + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findByTenantId(tenantId.getId(), pageLink); + } + + @Override + public List findNextBatch(UUID id, int batchSize) { + return userRepository.findNextBatch(id, Limit.of(batchSize)); + } + @Override public EntityType getEntityType() { return EntityType.USER; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserSettingsDao.java index bfe0e60556..646b11975e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserSettingsDao.java @@ -20,12 +20,14 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.settings.UserSettings; import org.thingsboard.server.common.data.settings.UserSettingsCompositeKey; import org.thingsboard.server.common.data.settings.UserSettingsType; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; import org.thingsboard.server.dao.model.sql.UserSettingsEntity; -import org.thingsboard.server.dao.sql.JpaAbstractDaoListeningExecutorService; import org.thingsboard.server.dao.user.UserSettingsDao; import org.thingsboard.server.dao.util.SqlDao; @@ -34,7 +36,7 @@ import java.util.List; @Slf4j @Component @SqlDao -public class JpaUserSettingsDao extends JpaAbstractDaoListeningExecutorService implements UserSettingsDao { +public class JpaUserSettingsDao implements UserSettingsDao, TenantEntityDao { @Autowired private UserSettingsRepository userSettingsRepository; @@ -66,4 +68,9 @@ public class JpaUserSettingsDao extends JpaAbstractDaoListeningExecutorService i return DaoUtil.convertDataList(userSettingsRepository.findByTypeAndPathExisting(type.name(), path)); } + @Override + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return DaoUtil.toPageData(userSettingsRepository.findByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink))); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserAuthSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserAuthSettingsRepository.java index 7ecb45d5e1..849cb22496 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserAuthSettingsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserAuthSettingsRepository.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.dao.sql.user; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -35,4 +37,7 @@ public interface UserAuthSettingsRepository extends JpaRepository findByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserCredentialsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserCredentialsRepository.java index 0dd3462c8b..51bc8702bc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserCredentialsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserCredentialsRepository.java @@ -15,9 +15,12 @@ */ package org.thingsboard.server.dao.sql.user; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.dao.model.sql.UserCredentialsEntity; @@ -52,4 +55,7 @@ public interface UserCredentialsRepository extends JpaRepository findByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserRepository.java index 731eda205e..0a30a859c6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserRepository.java @@ -15,15 +15,18 @@ */ package org.thingsboard.server.dao.sql.user; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.edqs.fields.UserFields; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.model.sql.UserEntity; import java.util.Collection; +import java.util.List; import java.util.UUID; /** @@ -71,4 +74,8 @@ public interface UserRepository extends JpaRepository { Long countByTenantId(UUID tenantId); + @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.UserFields(u.id, u.createdTime, u.tenantId," + + "u.customerId, u.version, u.firstName, u.lastName, u.email, u.phone, u.additionalInfo) " + + "FROM UserEntity u WHERE u.id > :id ORDER BY u.id") + List findNextBatch(@Param("id") UUID id, Limit limit); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserSettingsRepository.java index 12d43ff5ac..9423baafc0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserSettingsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserSettingsRepository.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.dao.sql.user; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -36,4 +38,7 @@ public interface UserSettingsRepository extends JpaRepository findByTypeAndPathExisting(@Param("type") String type, @Param("path") String[] path); + @Query("SELECT s FROM UserSettingsEntity s WHERE s.userId IN (SELECT u.id FROM UserEntity u WHERE u.tenantId = :tenantId)") + Page findByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java index df10804d50..c728f5d006 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java @@ -16,9 +16,11 @@ package org.thingsboard.server.dao.sql.widget; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.fields.WidgetTypeFields; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.WidgetTypeId; import org.thingsboard.server.common.data.page.PageData; @@ -30,6 +32,7 @@ import org.thingsboard.server.common.data.widget.WidgetTypeFilter; import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.common.data.widget.WidgetsBundleWidget; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.TenantEntityDao; import org.thingsboard.server.dao.model.sql.WidgetTypeDetailsEntity; import org.thingsboard.server.dao.model.sql.WidgetTypeInfoEntity; import org.thingsboard.server.dao.model.sql.WidgetsBundleWidgetCompositeKey; @@ -53,7 +56,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; */ @Component @SqlDao -public class JpaWidgetTypeDao extends JpaAbstractDao implements WidgetTypeDao { +public class JpaWidgetTypeDao extends JpaAbstractDao implements WidgetTypeDao, TenantEntityDao { @Autowired private WidgetTypeRepository widgetTypeRepository; @@ -256,10 +259,14 @@ public class JpaWidgetTypeDao extends JpaAbstractDao findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findByTenantId(tenantId.getId(), pageLink); } + @Override + public List findNextBatch(UUID id, int batchSize) { + return widgetTypeRepository.findNextBatch(id, Limit.of(batchSize)); + } @Override public List findByTenantIdAndResourceLink(TenantId tenantId, String link, int limit) { @@ -271,4 +278,9 @@ public class JpaWidgetTypeDao extends JpaAbstractDao implements WidgetsBundleDao { +public class JpaWidgetsBundleDao extends JpaAbstractDao implements WidgetsBundleDao, TenantEntityDao { @Autowired private WidgetsBundleRepository widgetsBundleRepository; @@ -155,7 +158,17 @@ public class JpaWidgetsBundleDao extends JpaAbstractDao findByImageLink(String imageUrl, int limit) { - return DaoUtil.convertDataList(widgetsBundleRepository.findByImageUrl(imageUrl, limit)); + return DaoUtil.convertDataList(widgetsBundleRepository.findByImageUrl(imageUrl, limit)); + } + + @Override + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findByTenantId(tenantId.getId(), pageLink); + } + + @Override + public List findNextBatch(UUID id, int batchSize) { + return widgetsBundleRepository.findNextBatch(id, Limit.of(batchSize)); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeRepository.java index e148c10d74..ffa88b4d54 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeRepository.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.dao.sql.widget; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.edqs.fields.WidgetTypeFields; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.WidgetTypeDetailsEntity; import org.thingsboard.server.dao.model.sql.WidgetTypeEntity; @@ -78,4 +80,7 @@ public interface WidgetTypeRepository extends JpaRepository findIdsByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable); + @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.WidgetTypeFields(w.id, w.createdTime, w.tenantId," + + "w.name, w.version) FROM WidgetTypeEntity w WHERE w.id > :id ORDER BY w.id") + List findNextBatch(@Param("id") UUID id, Limit limit); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetsBundleRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetsBundleRepository.java index 516d6a6642..de778588dd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetsBundleRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetsBundleRepository.java @@ -15,11 +15,14 @@ */ package org.thingsboard.server.dao.sql.widget; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.edqs.fields.WidgetTypeFields; +import org.thingsboard.server.common.data.edqs.fields.WidgetsBundleFields; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.WidgetsBundleEntity; @@ -139,4 +142,8 @@ public interface WidgetsBundleRepository extends JpaRepository findByImageUrl(@Param("imageLink") String imageLink, @Param("lmt") int lmt); + + @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.WidgetsBundleFields(w.id, w.createdTime, w.tenantId," + + "w.alias, w.version) FROM WidgetsBundleEntity w WHERE w.id > :id ORDER BY w.id") + List findNextBatch(@Param("id") UUID id, Limit limit); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractSqlTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractSqlTimeseriesDao.java index 664da621a5..6913aa8ea6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractSqlTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractSqlTimeseriesDao.java @@ -39,7 +39,6 @@ import java.util.Objects; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; -@SuppressWarnings("UnstableApiUsage") @Slf4j public abstract class AbstractSqlTimeseriesDao extends BaseAbstractSqlTimeseriesDao implements AggregationTimeseriesDao { @@ -119,4 +118,5 @@ public abstract class AbstractSqlTimeseriesDao extends BaseAbstractSqlTimeseries protected int getDataPointDays(TsKvEntry tsKvEntry, long ttl) { return tsKvEntry.getDataPoints() * Math.max(1, (int) (ttl / SECONDS_IN_DAY)); } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java index be5d74f758..6e43034a44 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java @@ -33,14 +33,18 @@ import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.stats.DefaultCounter; import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.dao.cache.CacheExecutorService; +import org.thingsboard.server.dao.model.sqlts.latest.TsKvLatestEntity; import org.thingsboard.server.dao.timeseries.TimeseriesLatestDao; import org.thingsboard.server.dao.timeseries.TsLatestCacheKey; import org.thingsboard.server.dao.util.SqlTsLatestAnyDaoCachedRedis; import java.util.List; +import java.util.Map; import java.util.Optional; @Slf4j @@ -167,4 +171,5 @@ public class CachedRedisSqlTimeseriesLatestDao extends BaseAbstractSqlTimeseries return sqlDao.findAllKeysByEntityIds(tenantId, entityIds); } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java index 5bb1c15e33..e8ef37b3b5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java @@ -24,6 +24,7 @@ import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; @@ -37,6 +38,8 @@ import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.dictionary.KeyDictionaryDao; @@ -185,6 +188,7 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme return tsKvLatestRepository.findAllKeysByEntityIds(entityIds.stream().map(EntityId::getId).collect(Collectors.toList())); } + private ListenableFuture getNewLatestEntryFuture(TenantId tenantId, EntityId entityId, DeleteTsKvQuery query) { ListenableFuture> future = findNewLatestEntryFuture(tenantId, entityId, query); return Futures.transformAsync(future, entryList -> { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/dictionary/JpaKeyDictionaryDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/dictionary/JpaKeyDictionaryDao.java index 93ad8e8beb..53a824f026 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/dictionary/JpaKeyDictionaryDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/dictionary/JpaKeyDictionaryDao.java @@ -22,6 +22,9 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.dictionary.KeyDictionaryDao; import org.thingsboard.server.dao.model.sqlts.dictionary.KeyDictionaryCompositeKey; import org.thingsboard.server.dao.model.sqlts.dictionary.KeyDictionaryEntry; @@ -92,4 +95,9 @@ public class JpaKeyDictionaryDao extends JpaAbstractDaoListeningExecutorService return byKeyId.map(KeyDictionaryEntry::getKey).orElse(null); } + @Override + public PageData findAll(PageLink pageLink) { + return DaoUtil.pageToPageData(keyDictionaryRepository.findAll(DaoUtil.toPageable(pageLink))); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/dictionary/KeyDictionaryRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/dictionary/KeyDictionaryRepository.java index 13d7481b00..d264cd9966 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/dictionary/KeyDictionaryRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/dictionary/KeyDictionaryRepository.java @@ -15,7 +15,10 @@ */ package org.thingsboard.server.dao.sqlts.dictionary; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.thingsboard.server.dao.model.sqlts.dictionary.KeyDictionaryCompositeKey; import org.thingsboard.server.dao.model.sqlts.dictionary.KeyDictionaryEntry; @@ -25,5 +28,7 @@ public interface KeyDictionaryRepository extends JpaRepository findByKeyId(int keyId); + @Query("SELECT e FROM KeyDictionaryEntry e ORDER BY e.keyId ASC") + Page findAll(Pageable pageable); } \ No newline at end of file diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/TsKvLatestRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/TsKvLatestRepository.java index 77db6cd734..29d4377485 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/TsKvLatestRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/TsKvLatestRepository.java @@ -41,4 +41,10 @@ public interface TsKvLatestRepository extends JpaRepository findAllKeysByEntityIds(@Param("entityIds") List entityIds); + @Query(value = "SELECT entity_id, key, ts, bool_v, str_v, long_v, dbl_v, json_v, version FROM ts_kv_latest WHERE (entity_id, key) > " + + "(:entityId, :key) ORDER BY entity_id, key LIMIT :batchSize", nativeQuery = true) + List findNextBatch(@Param("entityId") UUID entityId, + @Param("key") int key, + @Param("batchSize") int batchSize); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantDao.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantDao.java index e736bc791c..2a8269b15d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantDao.java @@ -37,10 +37,10 @@ public interface TenantDao extends Dao { * @return saved tenant object */ Tenant save(TenantId tenantId, Tenant tenant); - + /** * Find tenants by page link. - * + * * @param pageLink the page link * @return the list of tenant objects */ @@ -51,4 +51,5 @@ public interface TenantDao extends Dao { PageData findTenantsIds(PageLink pageLink); List findTenantIdsByTenantProfileId(TenantProfileId tenantProfileId); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index a9dc784132..a1137853ab 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -26,6 +26,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.ObjectType; +import org.thingsboard.server.common.data.edqs.LatestTsKv; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; @@ -36,8 +38,10 @@ import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult; +import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult; +import org.thingsboard.server.common.msg.edqs.EdqsService; import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.service.Validator; @@ -54,7 +58,6 @@ import static org.thingsboard.server.common.data.StringUtils.isBlank; /** * @author Andrew Shvayka */ -@SuppressWarnings("UnstableApiUsage") @Service @Slf4j public class BaseTimeseriesService implements TimeseriesService { @@ -89,6 +92,9 @@ public class BaseTimeseriesService implements TimeseriesService { @Autowired private EntityViewService entityViewService; + @Autowired + private EdqsService edqsService; + @Override public ListenableFuture> findAllByQueries(TenantId tenantId, EntityId entityId, List queries) { validate(entityId); @@ -156,60 +162,53 @@ public class BaseTimeseriesService implements TimeseriesService { } @Override - public ListenableFuture save(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) { + public ListenableFuture save(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) { validate(entityId); - List> futures = new ArrayList<>(INSERTS_PER_ENTRY); - saveAndRegisterFutures(tenantId, futures, entityId, tsKvEntry, 0L); - return Futures.transform(Futures.allAsList(futures), SUM_ALL_INTEGERS, MoreExecutors.directExecutor()); + return doSave(tenantId, entityId, List.of(tsKvEntry), 0L, true, true); } @Override - public ListenableFuture save(TenantId tenantId, EntityId entityId, List tsKvEntries, long ttl) { - return doSave(tenantId, entityId, tsKvEntries, ttl, true); + public ListenableFuture save(TenantId tenantId, EntityId entityId, List tsKvEntries, long ttl) { + return doSave(tenantId, entityId, tsKvEntries, ttl, true, true); } @Override - public ListenableFuture saveWithoutLatest(TenantId tenantId, EntityId entityId, List tsKvEntries, long ttl) { - return doSave(tenantId, entityId, tsKvEntries, ttl, false); - } - - private ListenableFuture doSave(TenantId tenantId, EntityId entityId, List tsKvEntries, long ttl, boolean saveLatest) { - int inserts = saveLatest ? INSERTS_PER_ENTRY : INSERTS_PER_ENTRY_WITHOUT_LATEST; - List> futures = new ArrayList<>(tsKvEntries.size() * inserts); - for (TsKvEntry tsKvEntry : tsKvEntries) { - if (saveLatest) { - saveAndRegisterFutures(tenantId, futures, entityId, tsKvEntry, ttl); - } else { - saveWithoutLatestAndRegisterFutures(tenantId, futures, entityId, tsKvEntry, ttl); - } - } - return Futures.transform(Futures.allAsList(futures), SUM_ALL_INTEGERS, MoreExecutors.directExecutor()); + public ListenableFuture saveWithoutLatest(TenantId tenantId, EntityId entityId, List tsKvEntries, long ttl) { + return doSave(tenantId, entityId, tsKvEntries, ttl, false, true); } @Override - public ListenableFuture> saveLatest(TenantId tenantId, EntityId entityId, List tsKvEntries) { - List> futures = new ArrayList<>(tsKvEntries.size()); - for (TsKvEntry tsKvEntry : tsKvEntries) { - futures.add(timeseriesLatestDao.saveLatest(tenantId, entityId, tsKvEntry)); - } - return Futures.allAsList(futures); - } - - private void saveAndRegisterFutures(TenantId tenantId, List> futures, EntityId entityId, TsKvEntry tsKvEntry, long ttl) { - doSaveAndRegisterFuturesFor(tenantId, futures, entityId, tsKvEntry, ttl); - futures.add(Futures.transform(timeseriesLatestDao.saveLatest(tenantId, entityId, tsKvEntry), v -> 0, MoreExecutors.directExecutor())); - } - - private void saveWithoutLatestAndRegisterFutures(TenantId tenantId, List> futures, EntityId entityId, TsKvEntry tsKvEntry, long ttl) { - doSaveAndRegisterFuturesFor(tenantId, futures, entityId, tsKvEntry, ttl); + public ListenableFuture saveLatest(TenantId tenantId, EntityId entityId, List tsKvEntries) { + return doSave(tenantId, entityId, tsKvEntries, 0L, true, false); } - private void doSaveAndRegisterFuturesFor(TenantId tenantId, List> futures, EntityId entityId, TsKvEntry tsKvEntry, long ttl) { - if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { + private ListenableFuture doSave(TenantId tenantId, EntityId entityId, List tsKvEntries, long ttl, boolean saveLatest, boolean saveTs) { + if (saveTs && entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { throw new IncorrectParameterException("Telemetry data can't be stored for entity view. Read only"); } - futures.add(timeseriesDao.savePartition(tenantId, entityId, tsKvEntry.getTs(), tsKvEntry.getKey())); - futures.add(timeseriesDao.save(tenantId, entityId, tsKvEntry, ttl)); + List> tsFutures = saveTs ? new ArrayList<>(tsKvEntries.size() * INSERTS_PER_ENTRY_WITHOUT_LATEST) : null; + List> latestFutures = saveLatest ? new ArrayList<>(tsKvEntries.size()) : null; + for (TsKvEntry tsKvEntry : tsKvEntries) { + if (saveTs) { + tsFutures.add(timeseriesDao.savePartition(tenantId, entityId, tsKvEntry.getTs(), tsKvEntry.getKey())); + tsFutures.add(timeseriesDao.save(tenantId, entityId, tsKvEntry, ttl)); + } + if (saveLatest) { + latestFutures.add(Futures.transform(timeseriesLatestDao.saveLatest(tenantId, entityId, tsKvEntry), version -> { + if (version != null) { + edqsService.onUpdate(tenantId, ObjectType.LATEST_TS_KV, new LatestTsKv(entityId, tsKvEntry, version)); + } + return version; + }, MoreExecutors.directExecutor())); + } + } + ListenableFuture dpsFuture = saveTs ? Futures.transform(Futures.allAsList(tsFutures), SUM_ALL_INTEGERS, MoreExecutors.directExecutor()) : Futures.immediateFuture(0); + ListenableFuture> versionsFuture = saveLatest ? Futures.allAsList(latestFutures) : Futures.immediateFuture(null); + return Futures.whenAllComplete(dpsFuture, versionsFuture).call(() -> { + Integer dataPoints = Futures.getUnchecked(dpsFuture); + List versions = Futures.getUnchecked(versionsFuture); + return TimeseriesSaveResult.of(dataPoints, versions); + }, MoreExecutors.directExecutor()); } private List updateQueriesForEntityView(EntityView entityView, List queries) { @@ -248,7 +247,7 @@ public class BaseTimeseriesService implements TimeseriesService { List> futures = new ArrayList<>(keys.size()); for (String key : keys) { DeleteTsKvQuery query = new BaseDeleteTsKvQuery(key, 0, System.currentTimeMillis(), false); - futures.add(timeseriesLatestDao.removeLatest(tenantId, entityId, query)); + futures.add(doRemove(tenantId, entityId, query)); } return Futures.allAsList(futures); } @@ -269,10 +268,20 @@ public class BaseTimeseriesService implements TimeseriesService { private void deleteAndRegisterFutures(TenantId tenantId, List> futures, EntityId entityId, DeleteTsKvQuery query) { futures.add(Futures.transform(timeseriesDao.remove(tenantId, entityId, query), v -> null, MoreExecutors.directExecutor())); if (query.getDeleteLatest()) { - futures.add(timeseriesLatestDao.removeLatest(tenantId, entityId, query)); + futures.add(doRemove(tenantId, entityId, query)); } } + private ListenableFuture doRemove(TenantId tenantId, EntityId entityId, DeleteTsKvQuery query) { + return Futures.transform(timeseriesLatestDao.removeLatest(tenantId, entityId, query), result -> { + if (result.isRemoved()) { + Long version = result.getVersion(); + edqsService.onDelete(tenantId, ObjectType.LATEST_TS_KV, new LatestTsKv(entityId, query.getKey(), version)); + } + return result; + }, MoreExecutors.directExecutor()); + } + private static void validate(EntityId entityId) { Validator.validateEntityId(entityId, id -> "Incorrect entityId " + id); } @@ -302,4 +311,5 @@ public class BaseTimeseriesService implements TimeseriesService { throw new IncorrectParameterException("Incorrect DeleteTsKvQuery. Key can't be empty"); } } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java index d4b31f4b92..54a7e68725 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java @@ -36,13 +36,17 @@ import org.thingsboard.server.common.data.kv.ReadTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.model.sqlts.latest.TsKvLatestEntity; import org.thingsboard.server.dao.nosql.TbResultSet; import org.thingsboard.server.dao.sqlts.AggregationTimeseriesDao; import org.thingsboard.server.dao.util.NoSqlTsLatestDao; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.literal; @@ -99,6 +103,7 @@ public class CassandraBaseTimeseriesLatestDao extends AbstractCassandraBaseTimes return Collections.emptyList(); } + @Override public ListenableFuture saveLatest(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) { BoundStatementBuilder stmtBuilder = new BoundStatementBuilder(getLatestStmt().bind()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java index 7f7fe88936..32479301ae 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java @@ -22,8 +22,12 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.model.sqlts.latest.TsKvLatestEntity; import java.util.List; +import java.util.Map; import java.util.Optional; public interface TimeseriesLatestDao { @@ -49,4 +53,5 @@ public interface TimeseriesLatestDao { List findAllKeysByDeviceProfileId(TenantId tenantId, DeviceProfileId deviceProfileId); List findAllKeysByEntityIds(TenantId tenantId, List entityIds); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateDao.java b/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateDao.java index 29fe557822..ffff210693 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateDao.java @@ -19,10 +19,11 @@ import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.Dao; +import org.thingsboard.server.dao.TenantEntityDao; import java.util.UUID; -public interface ApiUsageStateDao extends Dao { +public interface ApiUsageStateDao extends Dao, TenantEntityDao { /** * Save or update usage record object @@ -50,4 +51,5 @@ public interface ApiUsageStateDao extends Dao { void deleteApiUsageStateByTenantId(TenantId tenantId); void deleteApiUsageStateByEntityId(EntityId entityId); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java index 3a867587e1..1282493650 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java @@ -113,6 +113,14 @@ public class ApiUsageStateServiceImpl extends AbstractEntityService implements A ApiUsageState saved = apiUsageStateDao.save(apiUsageState.getTenantId(), apiUsageState); + eventPublisher.publishEvent(SaveEntityEvent.builder() + .tenantId(saved.getTenantId()) + .entityId(saved.getId()) + .entity(saved) + .created(true) + .broadcastEvent(false) + .build()); + List apiUsageStates = new ArrayList<>(); apiUsageStates.add(new BasicTsKvEntry(saved.getCreatedTime(), new StringDataEntry(ApiFeature.TRANSPORT.getApiStateKey(), ApiUsageStateValue.ENABLED.name()))); diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserDao.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserDao.java index c1d2a3f2e1..b60b263ac8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/user/UserDao.java @@ -28,7 +28,7 @@ import org.thingsboard.server.dao.TenantEntityDao; import java.util.List; import java.util.UUID; -public interface UserDao extends Dao, TenantEntityDao { +public interface UserDao extends Dao, TenantEntityDao { /** * Save or update user object diff --git a/dao/src/main/resources/sql/schema-entities-idx-psql-addon.sql b/dao/src/main/resources/sql/schema-entities-idx-psql-addon.sql index b0c69ef6f3..b756d282f8 100644 --- a/dao/src/main/resources/sql/schema-entities-idx-psql-addon.sql +++ b/dao/src/main/resources/sql/schema-entities-idx-psql-addon.sql @@ -36,3 +36,5 @@ CREATE INDEX IF NOT EXISTS idx_lc_event_main CREATE INDEX IF NOT EXISTS idx_error_event_main ON error_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST) WITH (FILLFACTOR=95); +CREATE INDEX IF NOT EXISTS idx_cf_debug_event_main + ON cf_debug_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST) WITH (FILLFACTOR=95); diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index b88c6bba50..b425550e7e 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -907,3 +907,44 @@ CREATE TABLE IF NOT EXISTS qr_code_settings ( qr_code_config VARCHAR(100000), CONSTRAINT qr_code_settings_tenant_id_unq_key UNIQUE (tenant_id) ); + +CREATE TABLE IF NOT EXISTS calculated_field ( + id uuid NOT NULL CONSTRAINT calculated_field_pkey PRIMARY KEY, + created_time bigint NOT NULL, + tenant_id uuid NOT NULL, + entity_type VARCHAR(32), + entity_id uuid NOT NULL, + type varchar(32) NOT NULL, + name varchar(255) NOT NULL, + configuration_version int DEFAULT 0, + configuration varchar(1000000), + version BIGINT DEFAULT 1, + debug_settings varchar(1024), + CONSTRAINT calculated_field_unq_key UNIQUE (entity_id, name) +); + +CREATE TABLE IF NOT EXISTS calculated_field_link ( + id uuid NOT NULL CONSTRAINT calculated_field_link_pkey PRIMARY KEY, + created_time bigint NOT NULL, + tenant_id uuid NOT NULL, + entity_type VARCHAR(32), + entity_id uuid NOT NULL, + calculated_field_id uuid NOT NULL, + CONSTRAINT fk_calculated_field_id FOREIGN KEY (calculated_field_id) REFERENCES calculated_field(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS cf_debug_event ( + id uuid NOT NULL, + tenant_id uuid NOT NULL , + ts bigint NOT NULL, + entity_id uuid NOT NULL, -- calculated field id + service_id varchar, + cf_id uuid NOT NULL, + e_entity_id uuid, -- target entity id + e_entity_type varchar, + e_msg_id uuid, + e_msg_type varchar, + e_args varchar, + e_result varchar, + e_error varchar +) PARTITION BY RANGE (ts); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java index fbad72f371..25339244fd 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java @@ -77,7 +77,6 @@ import java.util.UUID; import static org.junit.Assert.assertNotNull; - @RunWith(SpringRunner.class) @ContextConfiguration(classes = AbstractServiceTest.class, loader = AnnotationConfigContextLoader.class) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) @@ -131,7 +130,7 @@ public abstract class AbstractServiceTest { } public JsonNode readFromResource(String resourceName) throws IOException { - try (InputStream is = this.getClass().getClassLoader().getResourceAsStream(resourceName)){ + try (InputStream is = this.getClass().getClassLoader().getResourceAsStream(resourceName)) { return JacksonUtil.fromBytes(Objects.requireNonNull(is).readAllBytes()); } } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java index c82c6bd5f6..849528b091 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java @@ -22,6 +22,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmApiCallResult; @@ -48,6 +49,7 @@ import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.EntityDataSortOrder; import org.thingsboard.server.common.data.query.EntityKey; import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityListFilter; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.security.Authority; @@ -936,4 +938,53 @@ public class AlarmServiceTest extends AbstractServiceTest { Assert.assertEquals(0, alarms.getData().size()); } + @Test + public void testCountAlarmsForEntities() throws ExecutionException, InterruptedException { + AssetId parentId = new AssetId(Uuids.timeBased()); + AssetId childId = new AssetId(Uuids.timeBased()); + + EntityRelation relation = new EntityRelation(parentId, childId, EntityRelation.CONTAINS_TYPE); + + Assert.assertTrue(relationService.saveRelationAsync(tenantId, relation).get()); + + long ts = System.currentTimeMillis(); + AlarmApiCallResult result = alarmService.createAlarm(AlarmCreateOrUpdateActiveRequest.builder() + .tenantId(tenantId) + .originator(childId) + .type(TEST_ALARM) + .severity(AlarmSeverity.CRITICAL) + .startTs(ts).build()); + AlarmInfo created = result.getAlarm(); + created.setPropagate(true); + result = alarmService.updateAlarm(AlarmUpdateRequest.fromAlarm(created)); + created = result.getAlarm(); + + EntityListFilter entityListFilter = new EntityListFilter(); + entityListFilter.setEntityList(List.of(childId.getId().toString(), parentId.getId().toString())); + entityListFilter.setEntityType(EntityType.ASSET); + AlarmCountQuery countQuery = new AlarmCountQuery(entityListFilter); + countQuery.setStartTs(0L); + countQuery.setEndTs(System.currentTimeMillis()); + + long alarmsCount = alarmService.countAlarmsByQuery(tenantId, null, countQuery, List.of(childId)); + Assert.assertEquals(1, alarmsCount); + + countQuery.setSearchPropagatedAlarms(true); + + alarmsCount = alarmService.countAlarmsByQuery(tenantId, null, countQuery, List.of(parentId)); + Assert.assertEquals(1, alarmsCount); + + created = alarmService.acknowledgeAlarm(tenantId, created.getId(), System.currentTimeMillis()).getAlarm(); + + countQuery.setStatusList(List.of(AlarmSearchStatus.UNACK)); + alarmsCount = alarmService.countAlarmsByQuery(tenantId, null, countQuery, List.of(childId)); + Assert.assertEquals(0, alarmsCount); + + alarmService.clearAlarm(tenantId, created.getId(), System.currentTimeMillis(), null); + + countQuery.setStatusList(List.of(AlarmSearchStatus.CLEARED)); + alarmsCount = alarmService.countAlarmsByQuery(tenantId, null, countQuery, List.of(childId)); + Assert.assertEquals(1, alarmsCount); + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AssetServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AssetServiceTest.java index bcbc0f9be1..462e7a894c 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AssetServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AssetServiceTest.java @@ -30,6 +30,14 @@ import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetInfo; import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -39,6 +47,7 @@ import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.dao.asset.AssetDao; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.relation.RelationService; @@ -46,7 +55,9 @@ import org.thingsboard.server.dao.relation.RelationService; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; @DaoSqlTest @@ -63,6 +74,8 @@ public class AssetServiceTest extends AbstractServiceTest { @Autowired private AssetProfileService assetProfileService; @Autowired + private CalculatedFieldService calculatedFieldService; + @Autowired private PlatformTransactionManager platformTransactionManager; private IdComparator idComparator = new IdComparator<>(); @@ -214,24 +227,24 @@ public class AssetServiceTest extends AbstractServiceTest { public void testFindAssetTypesByTenantId() throws Exception { List assets = new ArrayList<>(); try { - for (int i=0;i<3;i++) { + for (int i = 0; i < 3; i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); - asset.setName("My asset B"+i); + asset.setName("My asset B" + i); asset.setType("typeB"); assets.add(assetService.saveAsset(asset)); } - for (int i=0;i<7;i++) { + for (int i = 0; i < 7; i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); - asset.setName("My asset C"+i); + asset.setName("My asset C" + i); asset.setType("typeC"); assets.add(assetService.saveAsset(asset)); } - for (int i=0;i<9;i++) { + for (int i = 0; i < 9; i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); - asset.setName("My asset A"+i); + asset.setName("My asset A" + i); asset.setType("typeA"); assets.add(assetService.saveAsset(asset)); } @@ -242,7 +255,9 @@ public class AssetServiceTest extends AbstractServiceTest { Assert.assertEquals("typeB", assetTypes.get(1).getType()); Assert.assertEquals("typeC", assetTypes.get(2).getType()); } finally { - assets.forEach((asset) -> { assetService.deleteAsset(tenantId, asset.getId()); }); + assets.forEach((asset) -> { + assetService.deleteAsset(tenantId, asset.getId()); + }); } } @@ -267,10 +282,10 @@ public class AssetServiceTest extends AbstractServiceTest { @Test public void testFindAssetsByTenantId() { List assets = new ArrayList<>(); - for (int i=0;i<13;i++) { + for (int i = 0; i < 13; i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); - asset.setName("Asset"+i); + asset.setName("Asset" + i); asset.setType("default"); assets.add(assetService.saveAsset(asset)); } @@ -303,11 +318,11 @@ public class AssetServiceTest extends AbstractServiceTest { public void testFindAssetsByTenantIdAndName() { String title1 = "Asset title 1"; List assetsTitle1 = new ArrayList<>(); - for (int i=0;i<13;i++) { + for (int i = 0; i < 13; i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); String suffix = StringUtils.randomAlphanumeric(15); - String name = title1+suffix; + String name = title1 + suffix; name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); asset.setName(name); asset.setType("default"); @@ -315,11 +330,11 @@ public class AssetServiceTest extends AbstractServiceTest { } String title2 = "Asset title 2"; List assetsTitle2 = new ArrayList<>(); - for (int i=0;i<17;i++) { + for (int i = 0; i < 17; i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); String suffix = StringUtils.randomAlphanumeric(15); - String name = title2+suffix; + String name = title2 + suffix; name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); asset.setName(name); asset.setType("default"); @@ -381,11 +396,11 @@ public class AssetServiceTest extends AbstractServiceTest { String title1 = "Asset title 1"; String type1 = "typeA"; List assetsType1 = new ArrayList<>(); - for (int i=0;i<13;i++) { + for (int i = 0; i < 13; i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); String suffix = StringUtils.randomAlphanumeric(15); - String name = title1+suffix; + String name = title1 + suffix; name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); asset.setName(name); asset.setType(type1); @@ -394,11 +409,11 @@ public class AssetServiceTest extends AbstractServiceTest { String title2 = "Asset title 2"; String type2 = "typeB"; List assetsType2 = new ArrayList<>(); - for (int i=0;i<17;i++) { + for (int i = 0; i < 17; i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); String suffix = StringUtils.randomAlphanumeric(15); - String name = title2+suffix; + String name = title2 + suffix; name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); asset.setName(name); asset.setType(type2); @@ -464,10 +479,10 @@ public class AssetServiceTest extends AbstractServiceTest { CustomerId customerId = customer.getId(); List assets = new ArrayList<>(); - for (int i=0;i<13;i++) { + for (int i = 0; i < 13; i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); - asset.setName("Asset"+i); + asset.setName("Asset" + i); asset.setType("default"); asset = assetService.saveAsset(asset); assets.add(new AssetInfo(assetService.assignAssetToCustomer(tenantId, asset.getId(), customerId), customer.getTitle(), customer.isPublic(), "default")); @@ -508,11 +523,11 @@ public class AssetServiceTest extends AbstractServiceTest { String title1 = "Asset title 1"; List assetsTitle1 = new ArrayList<>(); - for (int i=0;i<17;i++) { + for (int i = 0; i < 17; i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); String suffix = StringUtils.randomAlphanumeric(15); - String name = title1+suffix; + String name = title1 + suffix; name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); asset.setName(name); asset.setType("default"); @@ -521,11 +536,11 @@ public class AssetServiceTest extends AbstractServiceTest { } String title2 = "Asset title 2"; List assetsTitle2 = new ArrayList<>(); - for (int i=0;i<13;i++) { + for (int i = 0; i < 13; i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); String suffix = StringUtils.randomAlphanumeric(15); - String name = title2+suffix; + String name = title2 + suffix; name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); asset.setName(name); asset.setType("default"); @@ -596,11 +611,11 @@ public class AssetServiceTest extends AbstractServiceTest { String title1 = "Asset title 1"; String type1 = "typeC"; List assetsType1 = new ArrayList<>(); - for (int i=0;i<17;i++) { + for (int i = 0; i < 17; i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); String suffix = StringUtils.randomAlphanumeric(15); - String name = title1+suffix; + String name = title1 + suffix; name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); asset.setName(name); asset.setType(type1); @@ -610,11 +625,11 @@ public class AssetServiceTest extends AbstractServiceTest { String title2 = "Asset title 2"; String type2 = "typeD"; List assetsType2 = new ArrayList<>(); - for (int i=0;i<13;i++) { + for (int i = 0; i < 13; i++) { Asset asset = new Asset(); asset.setTenantId(tenantId); String suffix = StringUtils.randomAlphanumeric(15); - String name = title2+suffix; + String name = title2 + suffix; name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); asset.setName(name); asset.setType(type2); @@ -848,4 +863,52 @@ public class AssetServiceTest extends AbstractServiceTest { ); } + @Test + public void testDeleteAssetIfReferencedInCalculatedField() { + Asset asset = new Asset(); + asset.setTenantId(tenantId); + asset.setName("My asset"); + asset.setType("default"); + Asset savedAsset = assetService.saveAsset(asset); + + Asset assetWithCf = new Asset(); + assetWithCf.setTenantId(tenantId); + assetWithCf.setName("Asset with CF"); + assetWithCf.setType("default"); + Asset savedAssetWithCf = assetService.saveAsset(assetWithCf); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setTenantId(tenantId); + calculatedField.setName("Test CF"); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setEntityId(savedAssetWithCf.getId()); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + argument.setRefEntityId(savedAsset.getId()); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); + argument.setRefEntityKey(refEntityKey); + + config.setArguments(Map.of("T", argument)); + + config.setExpression("T - (100 - H) / 5"); + + Output output = new Output(); + output.setName("output"); + output.setType(OutputType.TIME_SERIES); + + config.setOutput(output); + + calculatedField.setConfiguration(config); + + CalculatedField savedCalculatedField = calculatedFieldService.save(calculatedField); + + assertThatThrownBy(() -> assetService.deleteAsset(tenantId, savedAsset.getId())) + .isInstanceOf(DataValidationException.class) + .hasMessage("Can't delete asset that has entity views or is referenced in calculated fields!"); + + calculatedFieldService.deleteCalculatedField(tenantId, savedCalculatedField.getId()); + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java new file mode 100644 index 0000000000..2985aa7620 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java @@ -0,0 +1,168 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.service; + +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.dao.cf.CalculatedFieldService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.exception.DataValidationException; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DaoSqlTest +public class CalculatedFieldServiceTest extends AbstractServiceTest { + + @Autowired + private CalculatedFieldService calculatedFieldService; + @Autowired + private DeviceService deviceService; + + private ListeningExecutorService executor; + + @Before + public void before() { + executor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(8, getClass())); + } + + @After + public void after() { + executor.shutdownNow(); + } + + @Test + public void testSaveCalculatedField() { + Device device = createTestDevice(); + CalculatedField calculatedField = getCalculatedField(device.getId(), device.getId()); + CalculatedField savedCalculatedField = calculatedFieldService.save(calculatedField); + + assertThat(savedCalculatedField).isNotNull(); + assertThat(savedCalculatedField.getId()).isNotNull(); + assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0); + assertThat(savedCalculatedField.getTenantId()).isEqualTo(calculatedField.getTenantId()); + assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId()); + assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); + assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); + assertThat(savedCalculatedField.getConfiguration()).isEqualTo(calculatedField.getConfiguration()); + assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); + + savedCalculatedField.setName("Test CF"); + + CalculatedField updatedCalculatedField = calculatedFieldService.save(savedCalculatedField); + + assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName()); + assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1); + + calculatedFieldService.deleteCalculatedField(tenantId, savedCalculatedField.getId()); + } + + @Test + public void testSaveCalculatedFieldWithExistingName() { + Device device = createTestDevice(); + CalculatedField calculatedField = getCalculatedField(device.getId(), device.getId()); + calculatedFieldService.save(calculatedField); + + assertThatThrownBy(() -> calculatedFieldService.save(calculatedField)) + .isInstanceOf(DataValidationException.class) + .hasMessage("Calculated Field with such name is already in exists!"); + } + + @Test + public void testFindCalculatedFieldById() { + CalculatedField savedCalculatedField = saveValidCalculatedField(); + CalculatedField fetchedCalculatedField = calculatedFieldService.findById(tenantId, savedCalculatedField.getId()); + + assertThat(fetchedCalculatedField).isEqualTo(savedCalculatedField); + + calculatedFieldService.deleteCalculatedField(tenantId, savedCalculatedField.getId()); + } + + @Test + public void testDeleteCalculatedField() { + CalculatedField savedCalculatedField = saveValidCalculatedField(); + + calculatedFieldService.deleteCalculatedField(tenantId, savedCalculatedField.getId()); + + assertThat(calculatedFieldService.findById(tenantId, savedCalculatedField.getId())).isNull(); + } + + private CalculatedField saveValidCalculatedField() { + Device device = createTestDevice(); + CalculatedField calculatedField = getCalculatedField(device.getId(), device.getId()); + return calculatedFieldService.save(calculatedField); + } + + private CalculatedField getCalculatedField(EntityId entityId, EntityId referencedEntityId) { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setTenantId(tenantId); + calculatedField.setEntityId(entityId); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("Test Calculated Field"); + calculatedField.setConfigurationVersion(1); + calculatedField.setConfiguration(getCalculatedFieldConfig(referencedEntityId)); + return calculatedField; + } + + private CalculatedFieldConfiguration getCalculatedFieldConfig(EntityId referencedEntityId) { + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + argument.setRefEntityId(referencedEntityId); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); + argument.setRefEntityKey(refEntityKey); + + config.setArguments(Map.of("T", argument)); + + config.setExpression("T - (100 - H) / 5"); + + Output output = new Output(); + output.setName("output"); + output.setType(OutputType.TIME_SERIES); + + config.setOutput(output); + + return config; + } + + private Device createTestDevice() { + Device device = new Device(); + device.setTenantId(tenantId); + device.setName("Test"); + return deviceService.saveDevice(device); + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/CustomerServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/CustomerServiceTest.java index 3e1dbe08a8..daa10e72e9 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/CustomerServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/CustomerServiceTest.java @@ -30,14 +30,26 @@ import org.testcontainers.shaded.org.awaitility.Awaitility; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.exception.DataValidationException; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; @@ -45,13 +57,17 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @DaoSqlTest public class CustomerServiceTest extends AbstractServiceTest { @Autowired CustomerService customerService; + @Autowired + CalculatedFieldService calculatedFieldService; + @Autowired + AssetService assetService; static final int TIMEOUT = 30; @@ -343,4 +359,51 @@ public class CustomerServiceTest extends AbstractServiceTest { } } + @Test + public void testDeleteCustomerIfReferencedInCalculatedField() { + Customer customer = new Customer(); + customer.setTenantId(tenantId); + customer.setTitle("My customer"); + Customer savedCustomer = customerService.saveCustomer(customer); + + Asset asset = new Asset(); + asset.setTenantId(tenantId); + asset.setName("My asset"); + asset.setType("default"); + Asset savedAsset = assetService.saveAsset(asset); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setTenantId(tenantId); + calculatedField.setName("Test CF"); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setEntityId(savedAsset.getId()); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + argument.setRefEntityId(savedCustomer.getId()); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); + argument.setRefEntityKey(refEntityKey); + + config.setArguments(Map.of("T", argument)); + + config.setExpression("T - (100 - H) / 5"); + + Output output = new Output(); + output.setName("output"); + output.setType(OutputType.TIME_SERIES); + + config.setOutput(output); + + calculatedField.setConfiguration(config); + + CalculatedField savedCalculatedField = calculatedFieldService.save(calculatedField); + + assertThatThrownBy(() -> customerService.deleteCustomer(tenantId, savedCustomer.getId())) + .isInstanceOf(DataValidationException.class) + .hasMessage("Can't delete customer that is referenced in calculated fields!"); + + calculatedFieldService.deleteCalculatedField(tenantId, savedCalculatedField.getId()); + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java index bf9c94dc89..bbbd48aa49 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java @@ -39,6 +39,14 @@ import org.thingsboard.server.common.data.OtaPackageInfo; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.OtaPackageId; @@ -50,6 +58,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.device.DeviceCredentialsService; import org.thingsboard.server.dao.device.DeviceProfileService; @@ -64,6 +73,7 @@ import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -87,6 +97,8 @@ public class DeviceServiceTest extends AbstractServiceTest { @Autowired TenantProfileService tenantProfileService; @Autowired + private CalculatedFieldService calculatedFieldService; + @Autowired private PlatformTransactionManager platformTransactionManager; @SpyBean private DeviceCredentialsDataValidator validator; @@ -1198,4 +1210,43 @@ public class DeviceServiceTest extends AbstractServiceTest { ); } + @Test + public void testDeleteDeviceIfReferencedInCalculatedField() { + Device device = saveDevice(tenantId, "Test Device"); + Device deviceWithCf = saveDevice(tenantId, "Device with CF"); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setTenantId(tenantId); + calculatedField.setName("Test CF"); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setEntityId(deviceWithCf.getId()); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + argument.setRefEntityId(device.getId()); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); + argument.setRefEntityKey(refEntityKey); + + config.setArguments(Map.of("T", argument)); + + config.setExpression("T - (100 - H) / 5"); + + Output output = new Output(); + output.setName("output"); + output.setType(OutputType.TIME_SERIES); + + config.setOutput(output); + + calculatedField.setConfiguration(config); + + CalculatedField savedCalculatedField = calculatedFieldService.save(calculatedField); + + assertThatThrownBy(() -> deviceService.deleteDevice(tenantId, device.getId())) + .isInstanceOf(DataValidationException.class) + .hasMessage("Can't delete device that has entity views or is referenced in calculated fields!"); + + calculatedFieldService.deleteCalculatedField(tenantId, savedCalculatedField.getId()); + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceRegistryTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceRegistryTest.java index 0e7797ce56..9503f4e23f 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceRegistryTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceRegistryTest.java @@ -20,6 +20,7 @@ import org.junit.Assert; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.entity.EntityDaoService; import org.thingsboard.server.dao.entity.EntityServiceRegistry; import org.thingsboard.server.dao.rule.RuleChainService; @@ -44,4 +45,8 @@ public class EntityServiceRegistryTest extends AbstractServiceTest { Assert.assertTrue(entityServiceRegistry.getServiceByEntityType(EntityType.RULE_NODE) instanceof RuleChainService); } + @Test + public void givenCalculatedFieldLinkEntityType_whenGetServiceByEntityTypeCalled_thenReturnedCalculatedFieldService() { + Assert.assertTrue(entityServiceRegistry.getServiceByEntityType(EntityType.CALCULATED_FIELD_LINK) instanceof CalculatedFieldService); + } } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidatorTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidatorTest.java new file mode 100644 index 0000000000..43fb44431e --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidatorTest.java @@ -0,0 +1,61 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.service.validator; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.cf.CalculatedFieldDao; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.usagerecord.DefaultApiLimitService; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@SpringBootTest(classes = CalculatedFieldDataValidator.class) +public class CalculatedFieldDataValidatorTest { + + private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("7b5229e9-166e-41a9-a257-3b1dafad1b04")); + private final CalculatedFieldId CALCULATED_FIELD_ID = new CalculatedFieldId(UUID.fromString("060fbe45-fbb2-4549-abf3-f72a6be3cb9f")); + + @MockBean + private CalculatedFieldDao calculatedFieldDao; + @MockBean + private DefaultApiLimitService apiLimitService; + @SpyBean + private CalculatedFieldDataValidator validator; + + @Test + public void testUpdateNonExistingCalculatedField() { + CalculatedField calculatedField = new CalculatedField(CALCULATED_FIELD_ID); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("Test"); + + given(calculatedFieldDao.findById(TENANT_ID, CALCULATED_FIELD_ID.getId())).willReturn(null); + + assertThatThrownBy(() -> validator.validateUpdate(TENANT_ID, calculatedField)) + .isInstanceOf(DataValidationException.class) + .hasMessage("Can't update non existing calculated field!"); + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/validator/CalculatedFieldLinkDataValidatorTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/validator/CalculatedFieldLinkDataValidatorTest.java new file mode 100644 index 0000000000..c477498602 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/validator/CalculatedFieldLinkDataValidatorTest.java @@ -0,0 +1,57 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.service.validator; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.CalculatedFieldLinkId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.cf.CalculatedFieldLinkDao; +import org.thingsboard.server.dao.exception.DataValidationException; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@SpringBootTest(classes = CalculatedFieldLinkDataValidator.class) +public class CalculatedFieldLinkDataValidatorTest { + + private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("2ba09d99-6143-43dc-b645-381fc0c43ebe")); + private final CalculatedFieldLinkId CALCULATED_FIELD_LINK_ID = new CalculatedFieldLinkId(UUID.fromString("a5609ef4-cb42-43ce-9b23-e090a4878d1c")); + + @MockBean + private CalculatedFieldLinkDao calculatedFieldLinkDao; + @SpyBean + private CalculatedFieldLinkDataValidator validator; + + @Test + public void testUpdateNonExistingCalculatedField() { + CalculatedFieldLink calculatedFieldLink = new CalculatedFieldLink(CALCULATED_FIELD_LINK_ID); + calculatedFieldLink.setCalculatedFieldId(new CalculatedFieldId(UUID.fromString("136477af-fd07-4498-b9c9-54fe50e82992"))); + + given(calculatedFieldLinkDao.findById(TENANT_ID, CALCULATED_FIELD_LINK_ID.getId())).willReturn(null); + + assertThatThrownBy(() -> validator.validateUpdate(TENANT_ID, calculatedFieldLink)) + .isInstanceOf(DataValidationException.class) + .hasMessage("Can't update non existing calculated field link!"); + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/query/DefaultQueryLogComponentTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/query/DefaultQueryLogComponentTest.java index e69c52056a..7e3ec8ba56 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/sql/query/DefaultQueryLogComponentTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/query/DefaultQueryLogComponentTest.java @@ -29,6 +29,8 @@ import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.context.junit4.SpringRunner; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.permission.QueryContext; +import org.thingsboard.server.common.data.permission.QueryContext; import java.util.List; import java.util.UUID; @@ -48,7 +50,7 @@ import static org.mockito.Mockito.times; public class DefaultQueryLogComponentTest { private TenantId tenantId; - private QueryContext ctx; + private SqlQueryContext ctx; @SpyBean private DefaultQueryLogComponent queryLog; @@ -56,7 +58,7 @@ public class DefaultQueryLogComponentTest { @Before public void setUp() { tenantId = new TenantId(UUID.fromString("97275c1c-9cf2-4d25-a68d-933031158f84")); - ctx = new QueryContext(new QuerySecurityContext(tenantId, null, EntityType.ALARM)); + ctx = new SqlQueryContext(new QueryContext(tenantId, null, EntityType.ALARM)); } @Test diff --git a/dao/src/test/resources/application-test.properties b/dao/src/test/resources/application-test.properties index 1e11c77f9a..a1bb335ad0 100644 --- a/dao/src/test/resources/application-test.properties +++ b/dao/src/test/resources/application-test.properties @@ -145,6 +145,8 @@ sql.events.batch_threads=2 actors.system.tenant_dispatcher_pool_size=4 actors.system.device_dispatcher_pool_size=8 actors.system.rule_dispatcher_pool_size=12 +actors.system.cfm_dispatcher_pool_size=2 +actors.system.cfe_dispatcher_pool_size=2 transport.sessions.report_timeout=10000 queue.transport_api.request_poll_interval=5 queue.transport_api.response_poll_interval=5 diff --git a/docker/.env b/docker/.env index 37c9768296..71722247df 100644 --- a/docker/.env +++ b/docker/.env @@ -14,6 +14,8 @@ COAP_TRANSPORT_DOCKER_NAME=tb-coap-transport LWM2M_TRANSPORT_DOCKER_NAME=tb-lwm2m-transport SNMP_TRANSPORT_DOCKER_NAME=tb-snmp-transport TB_VC_EXECUTOR_DOCKER_NAME=tb-vc-executor +EDQS_DOCKER_NAME=tb-edqs +EDQS_ENABLED=false TB_VERSION=latest diff --git a/docker/compose-utils.sh b/docker/compose-utils.sh index d5e60bee7f..3862024786 100755 --- a/docker/compose-utils.sh +++ b/docker/compose-utils.sh @@ -128,10 +128,21 @@ function additionalStartupServices() { echo $ADDITIONAL_STARTUP_SERVICES } +function additionalComposeEdqsArgs() { + source .env + + if [ "$EDQS_ENABLED" = true ] + then + ADDITIONAL_COMPOSE_EDQS_ARGS="-f docker-compose.edqs.yml" + echo ADDITIONAL_COMPOSE_EDQS_ARGS + else + echo "" + fi +} + function permissionList() { PERMISSION_LIST=" 799 799 tb-node/log - 799 799 tb-transports/coap/log 799 799 tb-transports/lwm2m/log 799 799 tb-transports/http/log 799 799 tb-transports/mqtt/log @@ -149,6 +160,12 @@ function permissionList() { " fi + if [ "$EDQS_ENABLED" = true ]; then + PERMISSION_LIST="$PERMISSION_LIST + 799 799 edqs/log + " + fi + CACHE="${CACHE:-redis}" case $CACHE in redis) @@ -182,29 +199,77 @@ function permissionList() { } function checkFolders() { + CREATE=false + SKIP_CHOWN=false + for i in "$@" + do + case $i in + --create) + CREATE=true + shift + ;; + --skipChown) + SKIP_CHOWN=true + shift + ;; + *) + # unknown option + ;; + esac + done EXIT_CODE=0 PERMISSION_LIST=$(permissionList) || exit $? set -e while read -r USR GRP DIR do - if [ -z "$DIR" ]; then # skip empty lines + IS_EXIST_CHECK_PASSED=false + IS_OWNER_CHECK_PASSED=false + + # skip empty lines + if [ -z "$DIR" ]; then continue fi - MESSAGE="Checking user ${USR} group ${GRP} dir ${DIR}" - if [[ -d "$DIR" ]] && - [[ $(ls -ldn "$DIR" | awk '{print $3}') -eq "$USR" ]] && - [[ $(ls -ldn "$DIR" | awk '{print $4}') -eq "$GRP" ]] - then - MESSAGE="$MESSAGE OK" + + # checks section + echo "Checking if dir ${DIR} exists..." + if [[ -d "$DIR" ]]; then + echo "> OK" + IS_EXIST_CHECK_PASSED=true + if [ "$SKIP_CHOWN" = false ]; then + echo "Checking user ${USR} group ${GRP} ownership for dir ${DIR}..." + if [[ $(ls -ldn "$DIR" | awk '{print $3}') -eq "$USR" ]] && [[ $(ls -ldn "$DIR" | awk '{print $4}') -eq "$GRP" ]]; then + echo "> OK" + IS_OWNER_CHECK_PASSED=true + else + echo "...ownership check failed" + if [ "$CREATE" = false ]; then + EXIT_CODE=1 + fi + fi + fi else - if [ "$1" = "--create" ]; then - echo "Create and chown: user ${USR} group ${GRP} dir ${DIR}" - mkdir -p "$DIR" && sudo chown -R "$USR":"$GRP" "$DIR" - else - echo "$MESSAGE FAILED" + echo "...does not exist" + if [ "$CREATE" = false ]; then EXIT_CODE=1 fi fi + + # create/chown section + if [ "$CREATE" = true ]; then + if [ "$IS_EXIST_CHECK_PASSED" = false ]; then + echo "...will create dir ${DIR}" + if [ "$SKIP_CHOWN" = false ]; then + echo "...will change ownership to user ${USR} group ${GRP} for dir ${DIR}" + mkdir -p "$DIR" && sudo chown -R "$USR":"$GRP" "$DIR" && echo "> OK" + else + mkdir -p "$DIR" && echo "> OK" + fi + elif [ "$IS_OWNER_CHECK_PASSED" = false ] && [ "$SKIP_CHOWN" = false ]; then + echo "...will change ownership to user ${USR} group ${GRP} for dir ${DIR}" + sudo chown -R "$USR":"$GRP" "$DIR" && echo "> OK" + fi + fi + done < <(echo "$PERMISSION_LIST") return $EXIT_CODE } diff --git a/docker/docker-check-log-folders.sh b/docker/docker-check-log-folders.sh index e293968a69..6122f3d2c1 100755 --- a/docker/docker-check-log-folders.sh +++ b/docker/docker-check-log-folders.sh @@ -17,5 +17,12 @@ set -e source compose-utils.sh -checkFolders || exit $? -echo "OK" +if checkFolders "$@" ; then + echo "------" + echo "All checks have passed" +else + CHECK_EXIT_CODE=$? + echo "------" + echo "Some checks did not pass - check the output" + exit $CHECK_EXIT_CODE +fi \ No newline at end of file diff --git a/docker/docker-compose.edqs.volumes.yml b/docker/docker-compose.edqs.volumes.yml new file mode 100644 index 0000000000..89b4b3a59c --- /dev/null +++ b/docker/docker-compose.edqs.volumes.yml @@ -0,0 +1,30 @@ +# +# 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. +# + +version: '3.0' + +services: + tb-edqs-1: + volumes: + - tb-edqs-log-volume:/var/log/tb-edqs + tb-edqs-2: + volumes: + - tb-edqs-log-volume:/var/log/tb-edqs + +volumes: + tb-edqs-log-volume: + external: + name: ${TB_EDQS_LOG_VOLUME} diff --git a/docker/docker-compose.edqs.yml b/docker/docker-compose.edqs.yml new file mode 100644 index 0000000000..6dd9606ee6 --- /dev/null +++ b/docker/docker-compose.edqs.yml @@ -0,0 +1,57 @@ +# +# 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. +# + +version: '3.0' + +services: + tb-core1: + env_file: + - tb-core-edqs.env + tb-core2: + env_file: + - tb-core-edqs.env + tb-rule-engine1: + env_file: + - tb-rule-engine-edqs.env + tb-rule-engine2: + env_file: + - tb-rule-engine-edqs.env + tb-edqs-1: + restart: always + image: "${DOCKER_REPO}/${EDQS_DOCKER_NAME}:${TB_VERSION}" + env_file: + - tb-edqs.env + volumes: + - ./tb-edqs/conf:/usr/share/tb-edqs/conf + - ./tb-edqs/log:/var/log/tb-edqs + ports: + - "8080" + depends_on: + - zookeeper + - kafka + tb-edqs-2: + restart: always + image: "${DOCKER_REPO}/${EDQS_DOCKER_NAME}:${TB_VERSION}" + env_file: + - tb-edqs.env + volumes: + - ./tb-edqs/conf:/usr/share/tb-edqs/conf + - ./tb-edqs/log:/var/log/tb-edqs + ports: + - "8080" + depends_on: + - zookeeper + - kafka diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 69b7722459..1cee5ad5ad 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -20,7 +20,7 @@ version: '3.0' services: zookeeper: restart: always - image: "zookeeper:3.8.0" + image: "zookeeper:3.8.1" ports: - "2181" environment: diff --git a/docker/docker-create-log-folders.sh b/docker/docker-create-log-folders.sh index 098ffabb31..ed66d4e156 100755 --- a/docker/docker-create-log-folders.sh +++ b/docker/docker-create-log-folders.sh @@ -17,4 +17,4 @@ set -e source compose-utils.sh -checkFolders --create +checkFolders --create "$@" diff --git a/docker/docker-install-tb.sh b/docker/docker-install-tb.sh index 1956e50eac..aa68a2252f 100755 --- a/docker/docker-install-tb.sh +++ b/docker/docker-install-tb.sh @@ -49,14 +49,15 @@ ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? ADDITIONAL_CACHE_ARGS=$(additionalComposeCacheArgs) || exit $? -ADDITIONAL_STARTUP_SERVICES=$(additionalStartupServices) || exit $? +ADDITIONAL_COMPOSE_EDQS_ARGS=$(additionalComposeEdqsArgs) || exit $? -checkFolders --create || exit $? +ADDITIONAL_STARTUP_SERVICES=$(additionalStartupServices) || exit $? if [ ! -z "${ADDITIONAL_STARTUP_SERVICES// }" ]; then COMPOSE_ARGS="\ - -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} \ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} + ${ADDITIONAL_COMPOSE_EDQS_ARGS} \ up -d ${ADDITIONAL_STARTUP_SERVICES}" case $COMPOSE_VERSION in @@ -73,7 +74,8 @@ if [ ! -z "${ADDITIONAL_STARTUP_SERVICES// }" ]; then fi COMPOSE_ARGS="\ - -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} \ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} + ${ADDITIONAL_COMPOSE_EDQS_ARGS} \ run --no-deps --rm -e INSTALL_TB=true -e LOAD_DEMO=${loadDemo} \ tb-core1" diff --git a/docker/docker-remove-services.sh b/docker/docker-remove-services.sh index 6b36f4be08..3124119abc 100755 --- a/docker/docker-remove-services.sh +++ b/docker/docker-remove-services.sh @@ -29,8 +29,10 @@ ADDITIONAL_CACHE_ARGS=$(additionalComposeCacheArgs) || exit $? ADDITIONAL_COMPOSE_MONITORING_ARGS=$(additionalComposeMonitoringArgs) || exit $? +ADDITIONAL_COMPOSE_EDQS_ARGS=$(additionalComposeEdqsArgs) || exit $? + COMPOSE_ARGS="\ - -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} ${ADDITIONAL_COMPOSE_MONITORING_ARGS} \ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} ${ADDITIONAL_COMPOSE_MONITORING_ARGS} ${ADDITIONAL_COMPOSE_EDQS_ARGS} \ down -v" case $COMPOSE_VERSION in diff --git a/docker/docker-start-services.sh b/docker/docker-start-services.sh index 3cdf10d00f..0d256abcf6 100755 --- a/docker/docker-start-services.sh +++ b/docker/docker-start-services.sh @@ -29,10 +29,10 @@ ADDITIONAL_CACHE_ARGS=$(additionalComposeCacheArgs) || exit $? ADDITIONAL_COMPOSE_MONITORING_ARGS=$(additionalComposeMonitoringArgs) || exit $? -checkFolders --create || exit $? +ADDITIONAL_COMPOSE_EDQS_ARGS=$(additionalComposeEdqsArgs) || exit $? COMPOSE_ARGS="\ - -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} ${ADDITIONAL_COMPOSE_MONITORING_ARGS} \ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} ${ADDITIONAL_COMPOSE_MONITORING_ARGS} ${ADDITIONAL_COMPOSE_EDQS_ARGS} \ up -d" case $COMPOSE_VERSION in diff --git a/docker/docker-stop-services.sh b/docker/docker-stop-services.sh index 670c44ca92..54386d10dd 100755 --- a/docker/docker-stop-services.sh +++ b/docker/docker-stop-services.sh @@ -29,8 +29,10 @@ ADDITIONAL_CACHE_ARGS=$(additionalComposeCacheArgs) || exit $? ADDITIONAL_COMPOSE_MONITORING_ARGS=$(additionalComposeMonitoringArgs) || exit $? +ADDITIONAL_COMPOSE_EDQS_ARGS=$(additionalComposeEdqsArgs) || exit $? + COMPOSE_ARGS="\ - -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} ${ADDITIONAL_COMPOSE_MONITORING_ARGS} \ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} ${ADDITIONAL_COMPOSE_MONITORING_ARGS} ${ADDITIONAL_COMPOSE_EDQS_ARGS}\ stop" case $COMPOSE_VERSION in diff --git a/docker/docker-update-service.sh b/docker/docker-update-service.sh index 7a77241e48..de1fe0a89a 100755 --- a/docker/docker-update-service.sh +++ b/docker/docker-update-service.sh @@ -27,12 +27,16 @@ ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? ADDITIONAL_CACHE_ARGS=$(additionalComposeCacheArgs) || exit $? +ADDITIONAL_COMPOSE_EDQS_ARGS=$(additionalComposeEdqsArgs) || exit $? + COMPOSE_ARGS_PULL="\ - -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} \ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} + ${ADDITIONAL_COMPOSE_EDQS_ARGS} \ pull" COMPOSE_ARGS_BUILD="\ - -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} \ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} + ${ADDITIONAL_COMPOSE_EDQS_ARGS} \ up -d --no-deps --build" case $COMPOSE_VERSION in diff --git a/docker/docker-upgrade-tb.sh b/docker/docker-upgrade-tb.sh index 3be5fbc14b..05293e475e 100755 --- a/docker/docker-upgrade-tb.sh +++ b/docker/docker-upgrade-tb.sh @@ -42,21 +42,24 @@ ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? ADDITIONAL_CACHE_ARGS=$(additionalComposeCacheArgs) || exit $? -ADDITIONAL_STARTUP_SERVICES=$(additionalStartupServices) || exit $? +ADDITIONAL_COMPOSE_EDQS_ARGS=$(additionalComposeEdqsArgs) || exit $? -checkFolders --create || exit $? +ADDITIONAL_STARTUP_SERVICES=$(additionalStartupServices) || exit $? COMPOSE_ARGS_PULL="\ - -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} \ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} + ${ADDITIONAL_COMPOSE_EDQS_ARGS} \ pull \ tb-core1" COMPOSE_ARGS_UP="\ - -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} \ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} + ${ADDITIONAL_COMPOSE_EDQS_ARGS} \ up -d ${ADDITIONAL_STARTUP_SERVICES}" COMPOSE_ARGS_RUN="\ - -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} \ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} + ${ADDITIONAL_COMPOSE_EDQS_ARGS} \ run --no-deps --rm -e UPGRADE_TB=true -e FROM_VERSION=${fromVersion} \ tb-core1" diff --git a/docker/monitoring/grafana/provisioning/dashboards/core_and_js_metrics.json b/docker/monitoring/grafana/provisioning/dashboards/core_and_js_metrics.json index e04a5c2d84..15ff06ab8c 100644 --- a/docker/monitoring/grafana/provisioning/dashboards/core_and_js_metrics.json +++ b/docker/monitoring/grafana/provisioning/dashboards/core_and_js_metrics.json @@ -223,8 +223,8 @@ "fill": 1, "fillGradient": 0, "gridPos": { - "h": 12, - "w": 24, + "h": 10, + "w": 12, "x": 0, "y": 10 }, @@ -303,6 +303,100 @@ "align": false, "alignLevel": null } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 10, + "w": 12, + "x": 12, + "y": 10 + }, + "hiddenSeries": false, + "id": 19, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName) (increase(tbelInvoke_total[1m]))", + "interval": "", + "legendFormat": "{{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "TbelInvoke Stats", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } } ], "schemaVersion": 27, diff --git a/docker/monitoring/grafana/provisioning/dashboards/edqs_entities.json b/docker/monitoring/grafana/provisioning/dashboards/edqs_entities.json new file mode 100644 index 0000000000..3e913d4856 --- /dev/null +++ b/docker/monitoring/grafana/provisioning/dashboards/edqs_entities.json @@ -0,0 +1,161 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 6, + "iteration": 1737564772936, + "links": [], + "liveNow": false, + "panels": [ + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 1, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "decimals": 0, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "9BonzvTSz" + }, + "exemplar": true, + "expr": "sum by (objectType) (edqs_object_count{tenantId=~\"$tenantId\"})", + "interval": "", + "legendFormat": "{{objectType}}", + "refId": "A" + } + ], + "title": "EDQS object count", + "type": "timeseries" + } + ], + "refresh": "", + "schemaVersion": 35, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "definition": "label_values(edqs_object_count, tenantId)", + "hide": 0, + "includeAll": true, + "label": "Tenant", + "multi": true, + "name": "tenantId", + "options": [], + "query": { + "query": "label_values(edqs_object_count, tenantId)", + "refId": "StandardVariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "EDQS", + "uid": "mK5A_DdHk", + "version": 9, + "weekStart": "" +} \ No newline at end of file diff --git a/docker/tb-core-edqs.env b/docker/tb-core-edqs.env new file mode 100644 index 0000000000..45472ec907 --- /dev/null +++ b/docker/tb-core-edqs.env @@ -0,0 +1,5 @@ +# ThingsBoard server configuration with enabled EDQS synchronization + +TB_EDQS_MODE=remote +TB_EDQS_SYNC_ENABLED=true +TB_EDQS_API_SUPPORTED=true diff --git a/docker/tb-edqs.env b/docker/tb-edqs.env new file mode 100644 index 0000000000..2c07d6e80d --- /dev/null +++ b/docker/tb-edqs.env @@ -0,0 +1,7 @@ +ZOOKEEPER_ENABLED=true +ZOOKEEPER_URL=zookeeper:2181 +TB_KAFKA_SERVERS=kafka:9092 +HTTP_BIND_PORT=8080 + +METRICS_ENABLED=true +METRICS_ENDPOINTS_EXPOSE=prometheus diff --git a/docker/tb-edqs/conf/logback.xml b/docker/tb-edqs/conf/logback.xml new file mode 100644 index 0000000000..14b5b0f04b --- /dev/null +++ b/docker/tb-edqs/conf/logback.xml @@ -0,0 +1,52 @@ + + + + + + + /var/log/tb-edqs/${TB_SERVICE_ID}/tb-edqs.log + + /var/log/tb-edqs/tb-edqs.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + diff --git a/docker/tb-edqs/conf/tb-edqs.conf b/docker/tb-edqs/conf/tb-edqs.conf new file mode 100644 index 0000000000..a5a4e6a10c --- /dev/null +++ b/docker/tb-edqs/conf/tb-edqs.conf @@ -0,0 +1,22 @@ +# +# 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. +# + +export JAVA_OPTS="$JAVA_OPTS -Xlog:gc*,heap*,age*,safepoint=debug:file=/var/log/tb-edqs/${TB_SERVICE_ID}-gc.log:time,uptime,level,tags:filecount=10,filesize=10M" +export JAVA_OPTS="$JAVA_OPTS -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError" +export JAVA_OPTS="$JAVA_OPTS -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:+UseG1GC -XX:MaxGCPauseMillis=500 -XX:+UseStringDeduplication -XX:+ParallelRefProcEnabled -XX:MaxTenuringThreshold=10" +export LOG_FILENAME=tb-edqs.out +export LOADER_PATH=/usr/share/tb-edqs/conf diff --git a/docker/tb-rule-engine-edqs.env b/docker/tb-rule-engine-edqs.env new file mode 100644 index 0000000000..82395ddcfe --- /dev/null +++ b/docker/tb-rule-engine-edqs.env @@ -0,0 +1,3 @@ +# ThingsBoard server configuration with enabled EDQS synchronization + +TB_EDQS_SYNC_ENABLED=true diff --git a/edqs/pom.xml b/edqs/pom.xml new file mode 100644 index 0000000000..07b25102d4 --- /dev/null +++ b/edqs/pom.xml @@ -0,0 +1,213 @@ + + + 4.0.0 + + org.thingsboard + 4.0.0-SNAPSHOT + thingsboard + + edqs + jar + + ThingsBoard Entity Data Query Service Application + https://thingsboard.io + + + UTF-8 + ${basedir}/.. + java + false + process-resources + package + edqs + ${project.build.directory}/windows + true + ThingsBoard Entity Data Query Service + org.thingsboard.server.edqs.ThingsboardEdqsApplication + + + + + org.thingsboard.common + edqs + + + org.slf4j + slf4j-api + + + org.slf4j + log4j-over-slf4j + + + ch.qos.logback + logback-core + + + ch.qos.logback + logback-classic + + + org.apache.curator + curator-recipes + + + com.google.protobuf + protobuf-java + + + io.grpc + grpc-protobuf + + + io.grpc + grpc-stub + + + + com.sun.winsw + winsw + bin + exe + provided + + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.vintage + junit-vintage-engine + test + + + org.awaitility + awaitility + test + + + org.assertj + assertj-core + test + + + + + ${pkg.name}-${project.version} + + + ${project.basedir}/src/main/resources + true + + edqs.yml + + + + ${project.basedir}/src/main/resources + false + + edqs.yml + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + + thingsboard + + + **/nosql/*Test.java + + + **/*Test.java + **/*TestSuite.java + + + + + org.apache.maven.plugins + maven-resources-plugin + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-winsw-service + package + + + + + org.apache.maven.plugins + maven-jar-plugin + + + org.springframework.boot + spring-boot-maven-plugin + + + org.thingsboard + gradle-maven-plugin + + + org.apache.maven.plugins + maven-assembly-plugin + + + org.apache.maven.plugins + maven-install-plugin + + + org.xolstice.maven.plugins + protobuf-maven-plugin + + + org.codehaus.mojo + build-helper-maven-plugin + + + org.apache.maven.plugins + maven-deploy-plugin + + true + + + + + + + jenkins + Jenkins Repository + https://repo.jenkins-ci.org/releases + + false + + + + diff --git a/edqs/src/main/conf/edqs.conf b/edqs/src/main/conf/edqs.conf new file mode 100644 index 0000000000..3f96fd590d --- /dev/null +++ b/edqs/src/main/conf/edqs.conf @@ -0,0 +1,22 @@ +# +# 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. +# + +export JAVA_OPTS="$JAVA_OPTS -Xlog:gc*,heap*,age*,safepoint=debug:file=@pkg.logFolder@/gc.log:time,uptime,level,tags:filecount=10,filesize=10M" +export JAVA_OPTS="$JAVA_OPTS -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError" +export JAVA_OPTS="$JAVA_OPTS -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:+UseG1GC -XX:MaxGCPauseMillis=500 -XX:+UseStringDeduplication -XX:+ParallelRefProcEnabled -XX:MaxTenuringThreshold=10" +export LOG_FILENAME=${pkg.name}.out +export LOADER_PATH=${pkg.installFolder}/conf diff --git a/edqs/src/main/conf/logback.xml b/edqs/src/main/conf/logback.xml new file mode 100644 index 0000000000..850a28b212 --- /dev/null +++ b/edqs/src/main/conf/logback.xml @@ -0,0 +1,49 @@ + + + + + + + ${pkg.logFolder}/${pkg.name}.log + + ${pkg.logFolder}/${pkg.name}.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + diff --git a/edqs/src/main/java/org/thingsboard/server/edqs/DummyQueueRoutingInfoService.java b/edqs/src/main/java/org/thingsboard/server/edqs/DummyQueueRoutingInfoService.java new file mode 100644 index 0000000000..1f1152af68 --- /dev/null +++ b/edqs/src/main/java/org/thingsboard/server/edqs/DummyQueueRoutingInfoService.java @@ -0,0 +1,33 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs; + +import org.springframework.stereotype.Service; +import org.thingsboard.server.queue.discovery.QueueRoutingInfo; +import org.thingsboard.server.queue.discovery.QueueRoutingInfoService; + +import java.util.Collections; +import java.util.List; + +@Service +public class DummyQueueRoutingInfoService implements QueueRoutingInfoService { + + @Override + public List getAllQueuesRoutingInfo() { + return Collections.emptyList(); + } + +} diff --git a/edqs/src/main/java/org/thingsboard/server/edqs/DummyTenantRoutingInfoService.java b/edqs/src/main/java/org/thingsboard/server/edqs/DummyTenantRoutingInfoService.java new file mode 100644 index 0000000000..4e16e5e16a --- /dev/null +++ b/edqs/src/main/java/org/thingsboard/server/edqs/DummyTenantRoutingInfoService.java @@ -0,0 +1,30 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs; + +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.queue.discovery.TenantRoutingInfo; +import org.thingsboard.server.queue.discovery.TenantRoutingInfoService; + +@Service +public class DummyTenantRoutingInfoService implements TenantRoutingInfoService { + @Override + public TenantRoutingInfo getRoutingInfo(TenantId tenantId) { + return null; + } + +} diff --git a/edqs/src/main/java/org/thingsboard/server/edqs/EdqsController.java b/edqs/src/main/java/org/thingsboard/server/edqs/EdqsController.java new file mode 100644 index 0000000000..4d0858ad5a --- /dev/null +++ b/edqs/src/main/java/org/thingsboard/server/edqs/EdqsController.java @@ -0,0 +1,41 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.server.edqs.state.EdqsStateService; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/edqs") +public class EdqsController { + + private final EdqsStateService edqsStateService; + + @GetMapping("/ready") + public ResponseEntity isReady() { + if (edqsStateService.isReady()) { + return ResponseEntity.ok().build(); + } else { + return ResponseEntity.badRequest().build(); + } + } + +} diff --git a/edqs/src/main/java/org/thingsboard/server/edqs/ThingsboardEdqsApplication.java b/edqs/src/main/java/org/thingsboard/server/edqs/ThingsboardEdqsApplication.java new file mode 100644 index 0000000000..00b3fdbd26 --- /dev/null +++ b/edqs/src/main/java/org/thingsboard/server/edqs/ThingsboardEdqsApplication.java @@ -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. + */ +package org.thingsboard.server.edqs; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +import java.util.Arrays; + +@SpringBootConfiguration +@EnableAsync +@EnableScheduling +@EnableAutoConfiguration +@ComponentScan({"org.thingsboard.server.edqs", "org.thingsboard.server.queue.edqs", "org.thingsboard.server.queue.discovery", "org.thingsboard.server.queue.kafka", + "org.thingsboard.server.queue.settings", "org.thingsboard.server.queue.environment", "org.thingsboard.server.common.stats"}) +@Slf4j +public class ThingsboardEdqsApplication { + + private static final String SPRING_CONFIG_NAME_KEY = "--spring.config.name"; + private static final String DEFAULT_SPRING_CONFIG_PARAM = SPRING_CONFIG_NAME_KEY + "=" + "edqs"; + + public static void main(String[] args) { + SpringApplication.run(ThingsboardEdqsApplication.class, updateArguments(args)); + } + + private static String[] updateArguments(String[] args) { + if (Arrays.stream(args).noneMatch(arg -> arg.startsWith(SPRING_CONFIG_NAME_KEY))) { + String[] modifiedArgs = new String[args.length + 1]; + System.arraycopy(args, 0, modifiedArgs, 0, args.length); + modifiedArgs[args.length] = DEFAULT_SPRING_CONFIG_PARAM; + return modifiedArgs; + } + return args; + } + +} diff --git a/edqs/src/main/resources/edqs.yml b/edqs/src/main/resources/edqs.yml new file mode 100644 index 0000000000..f7d0eda841 --- /dev/null +++ b/edqs/src/main/resources/edqs.yml @@ -0,0 +1,199 @@ +# +# 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. +# + +server: + # Server bind-address + address: "${HTTP_BIND_ADDRESS:0.0.0.0}" + # Server bind port + port: "${HTTP_BIND_PORT:8080}" + +# Application info parameters +app: + # Application version + version: "@project.version@" + +# Zookeeper connection parameters +zk: + # Enable/disable zookeeper discovery service. + enabled: "${ZOOKEEPER_ENABLED:true}" + # Zookeeper connect string + url: "${ZOOKEEPER_URL:localhost:2181}" + # Zookeeper retry interval in milliseconds + retry_interval_ms: "${ZOOKEEPER_RETRY_INTERVAL_MS:3000}" + # Zookeeper connection timeout in milliseconds + connection_timeout_ms: "${ZOOKEEPER_CONNECTION_TIMEOUT_MS:3000}" + # Zookeeper session timeout in milliseconds + session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}" + # Name of the directory in zookeeper 'filesystem' + zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}" + # The recalculate_delay property is recommended in a microservices architecture setup for rule-engine services. + # This property provides a pause to ensure that when a rule-engine service is restarted, other nodes don't immediately attempt to recalculate their partitions. + # The delay is recommended because the initialization of rule chain actors is time-consuming. Avoiding unnecessary recalculations during a restart can enhance system performance and stability. + recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}" + +spring: + main: + allow-circular-references: "true" # Spring Boot configuration property that controls whether circular dependencies between beans are allowed. + +# Queue configuration parameters +queue: + type: "${TB_QUEUE_TYPE:kafka}" # kafka (Apache Kafka) + prefix: "${TB_QUEUE_PREFIX:}" # Global queue prefix. If specified, prefix is added before default topic name: 'prefix.default_topic_name'. Prefix is applied to all topics (and consumer groups for kafka). + edqs: + # Number of partitions for EDQS topics + partitions: "${TB_EDQS_PARTITIONS:12}" + # EDQS partitioning strategy: tenant (partitions are resolved and distributed by tenant id) or none (partitions are resolved by message key; each instance has all the partitions) + partitioning_strategy: "${TB_EDQS_PARTITIONING_STRATEGY:tenant}" + # EDQS requests topic + requests_topic: "${TB_EDQS_REQUESTS_TOPIC:edqs.requests}" + # EDQS responses topic + responses_topic: "${TB_EDQS_RESPONSES_TOPIC:edqs.responses}" + # Poll interval for EDQS topics + poll_interval: "${TB_EDQS_POLL_INTERVAL_MS:125}" + # Maximum amount of pending requests to EDQS + max_pending_requests: "${TB_EDQS_MAX_PENDING_REQUESTS:10000}" + # Maximum timeout for requests to EDQS + max_request_timeout: "${TB_EDQS_MAX_REQUEST_TIMEOUT:20000}" + stats: + # Enable/disable statistics for EDQS + enabled: "${TB_EDQS_STATS_ENABLED:true}" + + kafka: + # Kafka Bootstrap nodes in "host:port" format + bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" + ssl: + # Enable/Disable SSL Kafka communication + enabled: "${TB_KAFKA_SSL_ENABLED:false}" + # The location of the trust store file + truststore.location: "${TB_KAFKA_SSL_TRUSTSTORE_LOCATION:}" + # The password of trust store file if specified + truststore.password: "${TB_KAFKA_SSL_TRUSTSTORE_PASSWORD:}" + # The location of the key store file. This is optional for the client and can be used for two-way authentication for the client + keystore.location: "${TB_KAFKA_SSL_KEYSTORE_LOCATION:}" + # The store password for the key store file. This is optional for the client and only needed if ‘ssl.keystore.location’ is configured. Key store password is not supported for PEM format + keystore.password: "${TB_KAFKA_SSL_KEYSTORE_PASSWORD:}" + # The password of the private key in the key store file or the PEM key specified in ‘keystore.key’ + key.password: "${TB_KAFKA_SSL_KEY_PASSWORD:}" + # The number of acknowledgments the producer requires the leader to have received before considering a request complete. This controls the durability of records that are sent. The following settings are allowed:0, 1 and all + acks: "${TB_KAFKA_ACKS:all}" + # Number of retries. Resend any record whose send fails with a potentially transient error + retries: "${TB_KAFKA_RETRIES:1}" + # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip + compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip + # Default batch size. This setting gives the upper bound of the batch size to be sent + batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" + # This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record + linger.ms: "${TB_KAFKA_LINGER_MS:1}" + # The maximum size of a request in bytes. This setting will limit the number of record batches the producer will send in a single request to avoid sending huge requests + max.request.size: "${TB_KAFKA_MAX_REQUEST_SIZE:1048576}" + # The maximum number of unacknowledged requests the client will send on a single connection before blocking + max.in.flight.requests.per.connection: "${TB_KAFKA_MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION:5}" + # The total bytes of memory the producer can use to buffer records waiting to be sent to the server + buffer.memory: "${TB_BUFFER_MEMORY:33554432}" + # The multiple copies of data over the multiple brokers of Kafka + replication_factor: "${TB_QUEUE_KAFKA_REPLICATION_FACTOR:1}" + # The maximum delay between invocations of poll() method when using consumer group management. This places an upper bound on the amount of time that the consumer can be idle before fetching more records + max_poll_interval_ms: "${TB_QUEUE_KAFKA_MAX_POLL_INTERVAL_MS:300000}" + # The maximum number of records returned in a single call of poll() method + max_poll_records: "${TB_QUEUE_KAFKA_MAX_POLL_RECORDS:8192}" + # The maximum amount of data per-partition the server will return. Records are fetched in batches by the consumer + max_partition_fetch_bytes: "${TB_QUEUE_KAFKA_MAX_PARTITION_FETCH_BYTES:16777216}" + # The maximum amount of data the server will return. Records are fetched in batches by the consumer + fetch_max_bytes: "${TB_QUEUE_KAFKA_FETCH_MAX_BYTES:134217728}" + request.timeout.ms: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms + session.timeout.ms: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms + auto_offset_reset: "${TB_QUEUE_KAFKA_AUTO_OFFSET_RESET:earliest}" # earliest, latest or none + # Enable/Disable using of Confluent Cloud + use_confluent_cloud: "${TB_QUEUE_KAFKA_USE_CONFLUENT_CLOUD:false}" + confluent: + # The endpoint identification algorithm used by clients to validate server hostname. The default value is https + ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}" + # The mechanism used to authenticate Schema Registry requests. SASL/PLAIN should only be used with TLS/SSL as a transport layer to ensure that clear passwords are not transmitted on the wire without encryption + sasl.mechanism: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM:PLAIN}" + # Using JAAS Configuration for specifying multiple SASL mechanisms on a broker + sasl.config: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_JAAS_CONFIG:org.apache.kafka.common.security.plain.PlainLoginModule required username=\"CLUSTER_API_KEY\" password=\"CLUSTER_API_SECRET\";}" + # Protocol used to communicate with brokers. Valid values are: PLAINTEXT, SSL, SASL_PLAINTEXT, SASL_SSL + security.protocol: "${TB_QUEUE_KAFKA_CONFLUENT_SECURITY_PROTOCOL:SASL_SSL}" + # Key-value properties for Kafka consumer per specific topic, e.g. tb_ota_package is a topic name for ota, tb_rule_engine.sq is a topic name for default SequentialByOriginator queue. + # Check TB_QUEUE_CORE_OTA_TOPIC and TB_QUEUE_RE_SQ_TOPIC params + consumer-properties-per-topic: + edqs.events: + # Key-value properties for Kafka consumer for edqs.events topic + - key: max.poll.records + # Max poll records for edqs.events topic + value: "${TB_QUEUE_KAFKA_EDQS_EVENTS_MAX_POLL_RECORDS:512}" + edqs.state: + # Key-value properties for Kafka consumer for edqs.state topic + - key: max.poll.records + # Max poll records for edqs.state topic + value: "${TB_QUEUE_KAFKA_EDQS_STATE_MAX_POLL_RECORDS:512}" + + other-inline: "${TB_QUEUE_KAFKA_OTHER_PROPERTIES:}" # In this section you can specify custom parameters (semicolon separated) for Kafka consumer/producer/admin # Example "metrics.recording.level:INFO;metrics.sample.window.ms:30000" + other: # DEPRECATED. In this section, you can specify custom parameters for Kafka consumer/producer and expose the env variables to configure outside + # - key: "request.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms + # value: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) + # - key: "session.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms + # value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) + topic-properties: + # Kafka properties for EDQS events topics. Partitions number must be the same as queue.edqs.partitions + edqs-events: "${TB_QUEUE_KAFKA_EDQS_EVENTS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:-1;partitions:12;min.insync.replicas:1}" + # Kafka properties for EDQS requests topic (default: 3 minutes retention). Partitions number must be the same as queue.edqs.partitions + edqs-requests: "${TB_QUEUE_KAFKA_EDQS_REQUESTS_TOPIC_PROPERTIES:retention.ms:180000;segment.bytes:52428800;retention.bytes:1048576000;partitions:12;min.insync.replicas:1}" + # Kafka properties for EDQS state topic (infinite retention, compaction). Partitions number must be the same as queue.edqs.partitions + edqs-state: "${TB_QUEUE_KAFKA_EDQS_STATE_TOPIC_PROPERTIES:retention.ms:-1;segment.bytes:52428800;retention.bytes:-1;partitions:12;min.insync.replicas:1;cleanup.policy:compact}" + consumer-stats: + # Prints lag between consumer group offset and last messages offset in Kafka topics + enabled: "${TB_QUEUE_KAFKA_CONSUMER_STATS_ENABLED:true}" + # Statistics printing interval for Kafka's consumer-groups stats + print-interval-ms: "${TB_QUEUE_KAFKA_CONSUMER_STATS_MIN_PRINT_INTERVAL_MS:60000}" + # Time to wait for the stats-loading requests to Kafka to finish + kafka-response-timeout-ms: "${TB_QUEUE_KAFKA_CONSUMER_STATS_RESPONSE_TIMEOUT_MS:1000}" + partitions: + hash_function_name: "${TB_QUEUE_PARTITIONS_HASH_FUNCTION_NAME:murmur3_128}" # murmur3_32, murmur3_128 or sha256 + +# General service parameters +service: + type: "${TB_SERVICE_TYPE:edqs}" + # Unique id for this service (autogenerated if empty) + id: "${TB_SERVICE_ID:}" + edqs: + # EDQS instances with the same label will share the same list of partitions + label: "${TB_EDQS_LABEL:}" + +# Metrics parameters +metrics: + # Enable/disable actuator metrics. + enabled: "${METRICS_ENABLED:false}" + timer: + # Metrics percentiles returned by actuator for timer metrics. List of double values (divided by ,). + percentiles: "${METRICS_TIMER_PERCENTILES:0.5}" + system_info: + # Persist frequency of system info (CPU, memory usage, etc.) in seconds + persist_frequency: "${METRICS_SYSTEM_INFO_PERSIST_FREQUENCY_SECONDS:60}" + # TTL in days for system info timeseries + ttl: "${METRICS_SYSTEM_INFO_TTL_DAYS:7}" + +# General management parameters +management: + endpoints: + web: + exposure: + # Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics). + include: '${METRICS_ENDPOINTS_EXPOSE:info}' + health: + elasticsearch: + # Enable the org.springframework.boot.actuate.elasticsearch.ElasticsearchRestClientHealthIndicator.doHealthCheck + enabled: "false" diff --git a/edqs/src/main/resources/logback.xml b/edqs/src/main/resources/logback.xml new file mode 100644 index 0000000000..5a6e9e3e24 --- /dev/null +++ b/edqs/src/main/resources/logback.xml @@ -0,0 +1,38 @@ + + + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/AbstractEDQTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/AbstractEDQTest.java new file mode 100644 index 0000000000..01a7495148 --- /dev/null +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/AbstractEDQTest.java @@ -0,0 +1,252 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import org.junit.After; +import org.junit.Before; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.TestExecutionListeners; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.context.support.DependencyInjectionTestExecutionListener; +import org.springframework.test.context.support.DirtiesContextTestExecutionListener; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileType; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edqs.EdqsObject; +import org.thingsboard.server.common.data.edqs.query.QueryResult; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityKeyValueType; +import org.thingsboard.server.common.data.query.FilterPredicateValue; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.StringFilterPredicate; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.edqs.util.EdqsConverter; + +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +@RunWith(SpringRunner.class) +@Configuration +@ComponentScan({"org.thingsboard.server.edqs.repo", "org.thingsboard.server.edqs.util"}) +@EntityScan("org.thingsboard.server.edqs") +@TestPropertySource(locations = {"classpath:edqs-test.properties"}) +@TestExecutionListeners({ + DependencyInjectionTestExecutionListener.class, + DirtiesContextTestExecutionListener.class}) +public abstract class AbstractEDQTest { + + @Autowired + protected DefaultEdqsRepository repository; + @Autowired + protected EdqsConverter edqsConverter; + + protected final TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + protected final CustomerId customerId = new CustomerId(UUID.randomUUID()); + + protected final UUID defaultAssetProfileId = UUID.randomUUID(); + protected final UUID defaultDeviceProfileId = UUID.randomUUID(); + + @Before + public final void before() { + AssetProfile ap = new AssetProfile(new AssetProfileId(defaultAssetProfileId)); + ap.setName("default"); + ap.setDefault(true); + addOrUpdate(EntityType.ASSET_PROFILE, ap); + + DeviceProfile dp = new DeviceProfile(new DeviceProfileId(defaultDeviceProfileId)); + dp.setName("default"); + dp.setDefault(true); + dp.setType(DeviceProfileType.DEFAULT); + addOrUpdate(EntityType.DEVICE_PROFILE, dp); + + createCustomer(customerId.getId(), null, "Customer A"); + } + + @After + public final void after() { + repository.clear(); + } + + protected void createCustomer(UUID id, UUID parentCustomerId, String title) { + Customer entity = new Customer(); + entity.setId(new CustomerId(id)); + entity.setTitle(title); + addOrUpdate(EntityType.CUSTOMER, entity); + } + + + protected UUID createDevice(String name) { + return createDevice(null, defaultDeviceProfileId, name); + } + + protected UUID createDevice(CustomerId customerId, String name) { + return createDevice(customerId.getId(), defaultDeviceProfileId, name); + } + + protected UUID createDevice(UUID customerId, UUID profileId, String name) { + UUID entityId = UUID.randomUUID(); + Device entity = new Device(); + entity.setId(new DeviceId(entityId)); + if (profileId != null) { + entity.setDeviceProfileId(new DeviceProfileId(profileId)); + } + if (customerId != null) { + entity.setCustomerId(new CustomerId(customerId)); + } + entity.setName(name); + addOrUpdate(EntityType.DEVICE, entity); + return entityId; + } + + protected UUID createDashboard(String name) { + UUID entityId = UUID.randomUUID(); + Dashboard entity = new Dashboard(); + entity.setId(new DashboardId(entityId)); + entity.setTitle(name); + addOrUpdate(EntityType.DEVICE, entity); + return entityId; + } + + protected UUID createView(String name) { + return createView(null, "default", name); + } + + protected UUID createView(CustomerId customerId, String name) { + return createView(customerId.getId(), "default", name); + } + + protected UUID createView(UUID customerId, String type, String name) { + UUID entityId = UUID.randomUUID(); + EntityView entity = new EntityView(); + entity.setId(new EntityViewId(entityId)); + entity.setType(type); + if (customerId != null) { + entity.setCustomerId(new CustomerId(customerId)); + } + entity.setName(name); + addOrUpdate(EntityType.ENTITY_VIEW, entity); + return entityId; + } + + protected UUID createEdge(String name) { + return createEdge(null, "default", name); + } + + protected UUID createEdge(CustomerId customerId, String name) { + return createEdge(customerId.getId(), "default", name); + } + + protected UUID createEdge(UUID customerId, String type, String name) { + UUID id = UUID.randomUUID(); + Edge edge = new Edge(); + edge.setId(new EdgeId(id)); + edge.setTenantId(tenantId); + if (customerId != null) { + edge.setCustomerId(new CustomerId(customerId)); + } + edge.setType(type); + edge.setName(name); + edge.setCreatedTime(42L); + addOrUpdate(EntityType.EDGE, edge); + return id; + } + + + protected UUID createAsset(String name) { + return createAsset(null, defaultAssetProfileId, name); + } + + protected UUID createAsset(UUID customerId, String name) { + return createAsset(customerId, defaultAssetProfileId, name); + } + + protected UUID createAsset(UUID customerId, UUID profileId, String name) { + UUID entityId = UUID.randomUUID(); + Asset entity = new Asset(); + entity.setId(new AssetId(entityId)); + if (profileId != null) { + entity.setAssetProfileId(new AssetProfileId(profileId)); + } + if (customerId != null) { + entity.setCustomerId(new CustomerId(customerId)); + } + entity.setName(name); + addOrUpdate(EntityType.ASSET, entity); + return entityId; + } + + protected void createRelation(EntityType fromType, UUID fromId, EntityType toType, UUID toId, String type) { + createRelation(fromType, fromId, toType, toId, RelationTypeGroup.COMMON, type); + } + + protected void createRelation(EntityType fromType, UUID fromId, EntityType toType, UUID toId, RelationTypeGroup group, String type) { + addOrUpdate(new EntityRelation(EntityIdFactory.getByTypeAndUuid(fromType, fromId), EntityIdFactory.getByTypeAndUuid(toType, toId), type, group)); + } + + + protected boolean checkContains(PageData data, UUID entityId) { + return data.getData().stream().anyMatch(r -> r.getEntityId().getId().equals(entityId)); + } + + protected List createStringKeyFilters(String key, EntityKeyType keyType, StringFilterPredicate.StringOperation operation, String value) { + KeyFilter filter = new KeyFilter(); + filter.setKey(new EntityKey(keyType, key)); + filter.setValueType(EntityKeyValueType.STRING); + StringFilterPredicate predicate = new StringFilterPredicate(); + predicate.setValue(FilterPredicateValue.fromString(value)); + predicate.setOperation(operation); + predicate.setIgnoreCase(true); + filter.setPredicate(predicate); + return Collections.singletonList(filter); + } + + protected void addOrUpdate(EntityType entityType, Object entity) { + addOrUpdate(EdqsConverter.toEntity(entityType, entity)); + } + + protected void addOrUpdate(EdqsObject edqsObject) { + byte[] serialized = edqsConverter.serialize(edqsObject.type(), edqsObject); + edqsObject = edqsConverter.deserialize(edqsObject.type(), serialized); + repository.get(tenantId).addOrUpdate(edqsObject); + } + +} diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/ApiUsageStateFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/ApiUsageStateFilterTest.java new file mode 100644 index 0000000000..e9470ca0b3 --- /dev/null +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/ApiUsageStateFilterTest.java @@ -0,0 +1,105 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.ApiUsageState; +import org.thingsboard.server.common.data.ApiUsageStateValue; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.id.ApiUsageStateId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.query.ApiUsageStateFilter; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityKeyValueType; +import org.thingsboard.server.common.data.query.FilterPredicateValue; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.StringFilterPredicate; + +import java.util.Arrays; +import java.util.UUID; + +public class ApiUsageStateFilterTest extends AbstractEDQTest { + + @Before + public void setUp() { + Tenant entity = new Tenant(); + entity.setId(tenantId); + entity.setTitle("test tenant"); + addOrUpdate(EntityType.TENANT, entity); + } + + @After + public void tearDown() { + } + + @Test + public void testFindCustomerApiUsageState() { + UUID customerId = UUID.randomUUID(); + createCustomer(customerId, null, "Customer A"); + + ApiUsageState apiUsageState = buildApiUsageState(customerId); + addOrUpdate(EntityType.API_USAGE_STATE, apiUsageState); + + var result = repository.findEntityDataByQuery(tenantId, null, getEntityDataQuery(new CustomerId(customerId)), false); + + Assert.assertEquals(1, result.getTotalElements()); + var customer = result.getData().get(0); + Assert.assertEquals("Customer A", customer.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()); + } + + private ApiUsageState buildApiUsageState(UUID customerId) { + ApiUsageState apiUsageState = new ApiUsageState(); + apiUsageState.setId(new ApiUsageStateId(UUID.randomUUID())); + apiUsageState.setTenantId(tenantId); + apiUsageState.setEntityId(new CustomerId(customerId)); + apiUsageState.setTransportState(ApiUsageStateValue.ENABLED); + apiUsageState.setReExecState(ApiUsageStateValue.ENABLED); + apiUsageState.setJsExecState(ApiUsageStateValue.ENABLED); + apiUsageState.setTbelExecState(ApiUsageStateValue.ENABLED); + apiUsageState.setDbStorageState(ApiUsageStateValue.ENABLED); + apiUsageState.setSmsExecState(ApiUsageStateValue.ENABLED); + apiUsageState.setEmailExecState(ApiUsageStateValue.ENABLED); + apiUsageState.setAlarmExecState(ApiUsageStateValue.ENABLED); + return apiUsageState; + } + + private static EntityDataQuery getEntityDataQuery(CustomerId customerId) { + ApiUsageStateFilter filter = new ApiUsageStateFilter(); + filter.setCustomerId(customerId); + var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.TIME_SERIES, "name"), EntityDataSortOrder.Direction.DESC), false); + + var entityFields = Arrays.asList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime")); + KeyFilter nameFilter = new KeyFilter(); + nameFilter.setKey(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + var predicate = new StringFilterPredicate(); + predicate.setIgnoreCase(false); + predicate.setOperation(StringFilterPredicate.StringOperation.CONTAINS); + predicate.setValue(new FilterPredicateValue<>("Customer A")); + nameFilter.setPredicate(predicate); + nameFilter.setValueType(EntityKeyValueType.STRING); + + return new EntityDataQuery(filter, pageLink, entityFields, null, Arrays.asList(nameFilter)); + } + +} diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/AssetSearchQueryFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/AssetSearchQueryFilterTest.java new file mode 100644 index 0000000000..1f90babf01 --- /dev/null +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/AssetSearchQueryFilterTest.java @@ -0,0 +1,147 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.edqs.query.QueryResult; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AssetSearchQueryFilter; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.StringFilterPredicate; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +public class AssetSearchQueryFilterTest extends AbstractEDQTest { + private final AssetProfileId assetProfileId = new AssetProfileId(UUID.randomUUID()); + + @Before + public void setUp() { + } + + @Test + public void testFindTenantAssets() { + AssetProfile assetProfile = new AssetProfile(assetProfileId); + assetProfile.setName("Office"); + assetProfile.setDefault(false); + addOrUpdate(EntityType.ASSET_PROFILE, assetProfile); + + UUID root = createAsset(null, assetProfileId.getId(), "root"); + UUID asset1 = createAsset(null, assetProfileId.getId(), "A1"); + UUID asset2 = createAsset(null, assetProfileId.getId(), "A2"); + + createRelation(EntityType.ASSET, root, EntityType.ASSET, asset1, "Contains"); + createRelation(EntityType.ASSET, asset1, EntityType.ASSET, asset2, "Contains"); + + // find all assets of root asset + PageData relationsResult = findData(null, new AssetId(root), + EntitySearchDirection.FROM, "Contains", 2, false, Arrays.asList("Office")); + Assert.assertEquals(2, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, asset1)); + Assert.assertTrue(checkContains(relationsResult, asset2)); + + // find all assets with max level = 1 + relationsResult = findData(null, new AssetId(root), + EntitySearchDirection.FROM, "Contains", 1, false, Arrays.asList("Office")); + Assert.assertEquals(1, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, asset1)); + + // find all assets with asset type = default + relationsResult = findData(null, new AssetId(root), + EntitySearchDirection.FROM, "Contains", 1, false, Arrays.asList("default")); + Assert.assertEquals(0, relationsResult.getData().size()); + + // find all assets last level only, level = 2 + relationsResult = findData(null, new AssetId(root), + EntitySearchDirection.FROM, "Contains", 2, true, Arrays.asList("Office")); + Assert.assertEquals(1, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, asset2)); + + // find all assets last level only, level = 1 + relationsResult = findData(null, new AssetId(root), + EntitySearchDirection.FROM, "Contains", 1, true, Arrays.asList("Office")); + Assert.assertEquals(1, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, asset1)); + } + + @Test + public void testFindCustomerAssets() { + AssetProfile assetProfile = new AssetProfile(assetProfileId); + assetProfile.setName("Office"); + assetProfile.setDefault(false); + addOrUpdate(EntityType.ASSET_PROFILE, assetProfile); + + UUID root = createAsset(customerId.getId(), assetProfileId.getId(), "root"); + UUID asset1 = createAsset(customerId.getId(), assetProfileId.getId(), "A1"); + UUID asset2 = createAsset(customerId.getId(), assetProfileId.getId(), "A2"); + UUID asset3 = createAsset(customerId.getId(), defaultAssetProfileId, "A3"); + + createRelation(EntityType.ASSET, root, EntityType.ASSET, asset1, "Contains"); + createRelation(EntityType.ASSET, root, EntityType.ASSET, asset3, "Contains"); + createRelation(EntityType.ASSET, asset1, EntityType.ASSET, asset2, "Contains"); + + // find all assets of root asset with profile "Office" + PageData relationsResult = findData(customerId, new AssetId(root), + EntitySearchDirection.FROM, "Contains", 2, false, Arrays.asList("Office")); + Assert.assertEquals(2, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, asset1)); + Assert.assertTrue(checkContains(relationsResult, asset2)); + + // find all assets of root asset with profile "Office" and "default" + relationsResult = findData(customerId, new AssetId(root), + EntitySearchDirection.FROM, "Contains", 2, false, Arrays.asList("Office", "default")); + Assert.assertEquals(3, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, asset1)); + Assert.assertTrue(checkContains(relationsResult, asset2)); + Assert.assertTrue(checkContains(relationsResult, asset3)); + + // find all assets with other customer + relationsResult = findData(new CustomerId(UUID.randomUUID()), new AssetId(root), + EntitySearchDirection.FROM, "Contains", 1, false, Arrays.asList("Office")); + Assert.assertEquals(0, relationsResult.getData().size()); + } + + + private PageData findData(CustomerId customerId, EntityId rootId, + EntitySearchDirection direction, String relationType, int maxLevel, boolean lastLevelOnly, List assetTypes) { + AssetSearchQueryFilter filter = new AssetSearchQueryFilter(); + filter.setRootEntity(rootId); + filter.setDirection(direction); + filter.setRelationType(relationType); + filter.setAssetTypes(assetTypes); + filter.setFetchLastLevelOnly(lastLevelOnly); + filter.setMaxLevel(maxLevel); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, null); + List keyFiltersEqualString = createStringKeyFilters("name", EntityKeyType.ENTITY_FIELD, StringFilterPredicate.StringOperation.STARTS_WITH, "A"); + EntityDataQuery query = new EntityDataQuery(filter, pageLink, Collections.emptyList(), Collections.emptyList(), keyFiltersEqualString); + return repository.findEntityDataByQuery(tenantId, customerId, query, false); + } + +} diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/AssetTypeFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/AssetTypeFilterTest.java new file mode 100644 index 0000000000..0961a7c12c --- /dev/null +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/AssetTypeFilterTest.java @@ -0,0 +1,185 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.edqs.LatestTsKv; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.query.AssetTypeFilter; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityKeyValueType; +import org.thingsboard.server.common.data.query.FilterPredicateValue; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.StringFilterPredicate; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AssetTypeFilterTest extends AbstractEDQTest { + + private final AssetProfileId assetProfileId = new AssetProfileId(UUID.randomUUID()); + private final AssetProfileId assetProfileId2 = new AssetProfileId(UUID.randomUUID()); + private Asset asset; + private Asset asset2; + private Asset asset3; + + @Before + public void setUp() { + AssetProfile assetProfile = new AssetProfile(assetProfileId); + assetProfile.setName("Office"); + assetProfile.setDefault(false); + addOrUpdate(EntityType.ASSET_PROFILE, assetProfile); + + AssetProfile assetProfile2 = new AssetProfile(assetProfileId2); + assetProfile2.setName("Street"); + assetProfile2.setDefault(false); + addOrUpdate(EntityType.ASSET_PROFILE, assetProfile2); + + asset = buildAsset(assetProfileId, "Office 1"); + asset2 = buildAsset(assetProfileId, "Office 2"); + asset3 = buildAsset(assetProfileId2, "Abbey Road"); + + addOrUpdate(EntityType.ASSET, asset); + addOrUpdate(EntityType.ASSET, asset2); + addOrUpdate(EntityType.ASSET, asset3); + } + + @After + public void tearDown() { + } + + @Test + public void testFindTenantAsset() { + // find asset with type "Office" + var result = repository.findEntityDataByQuery(tenantId, null, getAssetTypeQuery(Collections.singletonList("Office"), null, null), false); + + Assert.assertEquals(2, result.getTotalElements()); + var first = result.getData().stream().filter(queryResult -> queryResult.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue().equals("Office 1")).findAny(); + assertThat(first).isPresent(); + assertThat(first.get().getEntityId()).isEqualTo(asset.getId()); + assertThat(first.get().getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()).isEqualTo(String.valueOf(asset.getCreatedTime())); + + // find asset with type "Office" and "Street" + result = repository.findEntityDataByQuery(tenantId, null, getAssetTypeQuery(List.of("Office", "Street"), null, null), false); + + Assert.assertEquals(3, result.getTotalElements()); + var third = result.getData().stream().filter(queryResult -> queryResult.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue().equals("Abbey Road")).findAny(); + assertThat(third).isPresent(); + assertThat(third.get().getEntityId()).isEqualTo(asset3.getId()); + assertThat(third.get().getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()).isEqualTo(String.valueOf(asset.getCreatedTime())); + + // find asset with type "Supermarket" + result = repository.findEntityDataByQuery(tenantId, null, getAssetTypeQuery(List.of("Supermarket"), null, null), false); + Assert.assertEquals(0, result.getTotalElements()); + + // find asset with name "%Office%" + result = repository.findEntityDataByQuery(tenantId, null, getAssetTypeQuery(List.of("Office"), "%Office%", null), false); + Assert.assertEquals(2, result.getTotalElements()); + + // find asset with name "Office 1" + result = repository.findEntityDataByQuery(tenantId, null, getAssetTypeQuery(List.of("Office"), "Office 1", null), false); + Assert.assertEquals(1, result.getTotalElements()); + + // find asset with name "%Super%" + result = repository.findEntityDataByQuery(tenantId, null, getAssetTypeQuery(List.of("Office"), "%Super%", null), false); + Assert.assertEquals(0, result.getTotalElements()); + + // find asset with key filter: name contains "Office" + KeyFilter containsNameFilter = getAssetNameKeyFilter(StringFilterPredicate.StringOperation.CONTAINS, "office", true); + result = repository.findEntityDataByQuery(tenantId, null, getAssetTypeQuery(List.of("Office"), null, Arrays.asList(containsNameFilter)), false); + Assert.assertEquals(2, result.getTotalElements()); + + // find asset with key filter: name starts with "office" and matches case + KeyFilter startsWithNameFilter = getAssetNameKeyFilter(StringFilterPredicate.StringOperation.STARTS_WITH, "office", false); + result = repository.findEntityDataByQuery(tenantId, null, getAssetTypeQuery(List.of("Office"), null, Arrays.asList(startsWithNameFilter)), false); + Assert.assertEquals(0, result.getTotalElements()); + } + + @Test + public void testFindCustomerAsset() { + addOrUpdate(EntityType.ASSET, asset); + addOrUpdate(new LatestTsKv(asset.getId(), new BasicTsKvEntry(43, new StringDataEntry("state", "TEST")), 0L)); + + var result = repository.findEntityDataByQuery(tenantId, customerId, getAssetTypeQuery(List.of("Office"), null, null), false); + Assert.assertEquals(0, result.getTotalElements()); + + asset.setCustomerId(customerId); + addOrUpdate(EntityType.ASSET, asset); + + result = repository.findEntityDataByQuery(tenantId, customerId, getAssetTypeQuery(List.of("Office"), null, null), false); + + Assert.assertEquals(1, result.getTotalElements()); + var first = result.getData().get(0); + Assert.assertEquals(asset.getId(), first.getEntityId()); + Assert.assertEquals("Office 1", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()); + Assert.assertEquals("42", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()); + + result = repository.findEntityDataByQuery(tenantId, customerId, getAssetTypeQuery(List.of("Supermarket"), null, null), false); + Assert.assertEquals(0, result.getTotalElements()); + } + + private Asset buildAsset(AssetProfileId assetProfileId, String assetName) { + Asset asset = new Asset(); + asset.setId(new AssetId(UUID.randomUUID())); + asset.setTenantId(tenantId); + asset.setAssetProfileId(assetProfileId); + asset.setName(assetName); + asset.setCreatedTime(42L); + return asset; + } + + private static EntityDataQuery getAssetTypeQuery(List assetTypes, String assetNameRegex, List keyFilters) { + AssetTypeFilter filter = new AssetTypeFilter(); + filter.setAssetTypes(assetTypes); + filter.setAssetNameFilter(assetNameRegex); + var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime"), EntityDataSortOrder.Direction.DESC), false); + + var entityFields = Arrays.asList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime")); + var latestValues = Arrays.asList(new EntityKey(EntityKeyType.TIME_SERIES, "state")); + + return new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); + } + + private static KeyFilter getAssetNameKeyFilter(StringFilterPredicate.StringOperation operation, String predicateValue, boolean ignoreCase) { + KeyFilter nameFilter = new KeyFilter(); + nameFilter.setKey(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + var predicate = new StringFilterPredicate(); + predicate.setIgnoreCase(ignoreCase); + predicate.setOperation(operation); + predicate.setValue(new FilterPredicateValue<>(predicateValue)); + nameFilter.setPredicate(predicate); + nameFilter.setValueType(EntityKeyValueType.STRING); + return nameFilter; + } + +} diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceSearchQueryFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceSearchQueryFilterTest.java new file mode 100644 index 0000000000..b3f15c2f61 --- /dev/null +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceSearchQueryFilterTest.java @@ -0,0 +1,150 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileType; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.query.QueryResult; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.DeviceSearchQueryFilter; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.StringFilterPredicate; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +public class DeviceSearchQueryFilterTest extends AbstractEDQTest { + private final DeviceProfileId deviceProfileId = new DeviceProfileId(UUID.randomUUID()); + + @Before + public void setUp() { + } + + @Test + public void testFindTenantDevices() { + DeviceProfile deviceProfile = new DeviceProfile(deviceProfileId); + deviceProfile.setName("thermostat"); + deviceProfile.setDefault(false); + deviceProfile.setType(DeviceProfileType.DEFAULT); + addOrUpdate(EntityType.DEVICE_PROFILE, deviceProfile); + + UUID asset1 = createAsset("A1"); + UUID asset2 = createAsset("A2"); + UUID device1 = createDevice(null, deviceProfileId.getId(), "D1"); + UUID device2 = createDevice(null, deviceProfileId.getId(), "D2"); + + createRelation(EntityType.ASSET, asset1, EntityType.DEVICE, device1, "Contains"); + createRelation(EntityType.ASSET, asset1, EntityType.ASSET, asset2, "Contains"); + createRelation(EntityType.ASSET, asset2, EntityType.DEVICE, device2, "Contains"); + + // find all devices of asset A1 + PageData relationsResult = findData(null, new AssetId(asset1), + EntitySearchDirection.FROM, "Contains", 2, false, Arrays.asList("thermostat")); + Assert.assertEquals(2, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, device1)); + Assert.assertTrue(checkContains(relationsResult, device2)); + + // find all devices with max level = 1 + relationsResult = findData(null, new AssetId(asset1), + EntitySearchDirection.FROM, "Contains", 1, false, Arrays.asList("thermostat")); + Assert.assertEquals(1, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, device1)); + + // find all devices with asset type = default + relationsResult = findData(null, new AssetId(asset1), + EntitySearchDirection.FROM, "Contains", 1, false, Arrays.asList("default")); + Assert.assertEquals(0, relationsResult.getData().size()); + + // find all devices last level only, level = 2 + relationsResult = findData(null, new AssetId(asset1), + EntitySearchDirection.FROM, "Contains", 2, true, Arrays.asList("thermostat")); + Assert.assertEquals(2, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, device2)); + Assert.assertTrue(checkContains(relationsResult, device1)); + + // find all devices last level only, level = 1 + relationsResult = findData(null, new AssetId(asset1), + EntitySearchDirection.FROM, "Contains", 1, true, Arrays.asList("thermostat")); + Assert.assertEquals(1, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, device1)); + } + + @Test + public void testFindCustomerDevices() { + DeviceProfile deviceProfile = new DeviceProfile(deviceProfileId); + deviceProfile.setName("thermostat"); + deviceProfile.setDefault(false); + deviceProfile.setType(DeviceProfileType.DEFAULT); + addOrUpdate(EntityType.DEVICE_PROFILE, deviceProfile); + + UUID asset1 = createAsset(customerId.getId(), defaultAssetProfileId, "A1"); + UUID asset2 = createAsset(customerId.getId(), defaultAssetProfileId, "A2"); + UUID device1 = createDevice(customerId.getId(), deviceProfileId.getId(), "D1"); + UUID device2 = createDevice(customerId.getId(), defaultDeviceProfileId, "D2"); + + createRelation(EntityType.ASSET, asset1, EntityType.ASSET, asset2, "Contains"); + createRelation(EntityType.ASSET, asset1, EntityType.DEVICE, device1, "Contains"); + createRelation(EntityType.ASSET, asset2, EntityType.DEVICE, device2, "Contains"); + + // find all devices of type "thermostat" + PageData relationsResult = findData(customerId, new AssetId(asset1), + EntitySearchDirection.FROM, "Contains", 2, false, Arrays.asList("thermostat")); + Assert.assertEquals(1, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, device1)); + + // find all assets of root asset with profile "Office" and "default" + relationsResult = findData(customerId, new AssetId(asset1), + EntitySearchDirection.FROM, "Contains", 2, false, Arrays.asList("thermostat", "default")); + Assert.assertEquals(2, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, device1)); + Assert.assertTrue(checkContains(relationsResult, device2)); + + // find all assets with other customer + relationsResult = findData(new CustomerId(UUID.randomUUID()), new AssetId(asset1), + EntitySearchDirection.FROM, "Contains", 2, false, Arrays.asList("thermostat")); + Assert.assertEquals(0, relationsResult.getData().size()); + } + + private PageData findData(CustomerId customerId, EntityId rootId, + EntitySearchDirection direction, String relationType, int maxLevel, boolean lastLevelOnly, List deviceTypes) { + DeviceSearchQueryFilter filter = new DeviceSearchQueryFilter(); + filter.setRootEntity(rootId); + filter.setDirection(direction); + filter.setRelationType(relationType); + filter.setDeviceTypes(deviceTypes); + filter.setFetchLastLevelOnly(lastLevelOnly); + filter.setMaxLevel(maxLevel); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, null); + List keyFiltersEqualString = createStringKeyFilters("name", EntityKeyType.ENTITY_FIELD, StringFilterPredicate.StringOperation.STARTS_WITH, "D"); + EntityDataQuery query = new EntityDataQuery(filter, pageLink, Collections.emptyList(), Collections.emptyList(), keyFiltersEqualString); + return repository.findEntityDataByQuery(tenantId, customerId, query, false); + } + +} diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java new file mode 100644 index 0000000000..e07dfc98e4 --- /dev/null +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java @@ -0,0 +1,141 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileType; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.LatestTsKv; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.query.DeviceTypeFilter; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityKeyValueType; +import org.thingsboard.server.common.data.query.FilterPredicateValue; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.StringFilterPredicate; + +import java.util.Arrays; +import java.util.Collections; +import java.util.UUID; + +public class DeviceTypeFilterTest extends AbstractEDQTest { + + private final DeviceProfileId loraProfileId = new DeviceProfileId(UUID.randomUUID()); + + @Before + public void setUp() { + DeviceProfile deviceProfile = new DeviceProfile(loraProfileId); + deviceProfile.setName("LoRa"); + deviceProfile.setDefault(false); + deviceProfile.setType(DeviceProfileType.DEFAULT); + addOrUpdate(EntityType.DEVICE_PROFILE, deviceProfile); + } + + @After + public void tearDown() { + } + + @Test + public void testFindTenantDevice() { + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + Device device = new Device(); + device.setId(deviceId); + device.setTenantId(tenantId); + device.setDeviceProfileId(loraProfileId); + device.setName("LoRa-1"); + device.setCreatedTime(42L); + addOrUpdate(EntityType.DEVICE, device); + + var result = repository.findEntityDataByQuery(tenantId, null, getDeviceTypeQuery("LoRa"), false); + + Assert.assertEquals(1, result.getTotalElements()); + var first = result.getData().get(0); + Assert.assertEquals(deviceId, first.getEntityId()); + Assert.assertEquals("LoRa-1", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()); + Assert.assertEquals("42", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()); + + result = repository.findEntityDataByQuery(tenantId, null, getDeviceTypeQuery("Not LoRa"), false); + Assert.assertEquals(0, result.getTotalElements()); + + device.setCustomerId(customerId); + addOrUpdate(EntityType.DEVICE, device); + + result = repository.findEntityDataByQuery(tenantId, customerId, getDeviceTypeQuery("LoRa"), false); + Assert.assertEquals(1, result.getTotalElements()); + result = repository.findEntityDataByQuery(tenantId, customerId, getDeviceTypeQuery("default"), false); + Assert.assertEquals(0, result.getTotalElements()); + } + + @Test + public void testFindCustomerDevice() { + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + Device device = new Device(); + device.setId(deviceId); + device.setTenantId(tenantId); + device.setName("LoRa-1"); + device.setCreatedTime(42L); + device.setDeviceProfileId(loraProfileId); + + addOrUpdate(EntityType.DEVICE, device); + addOrUpdate(new LatestTsKv(deviceId, new BasicTsKvEntry(43, new StringDataEntry("state", "TEST")), 0L)); + + var result = repository.findEntityDataByQuery(tenantId, customerId, getDeviceTypeQuery("LoRa"), false); + Assert.assertEquals(0, result.getTotalElements()); + + device.setCustomerId(customerId); + addOrUpdate(EntityType.DEVICE, device); + + result = repository.findEntityDataByQuery(tenantId, customerId, getDeviceTypeQuery("LoRa"), false); + + Assert.assertEquals(1, result.getTotalElements()); + var first = result.getData().get(0); + Assert.assertEquals(deviceId, first.getEntityId()); + Assert.assertEquals("LoRa-1", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()); + Assert.assertEquals("42", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()); + } + + private static EntityDataQuery getDeviceTypeQuery(String deviceType) { + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(Collections.singletonList(deviceType)); + var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.TIME_SERIES, "state"), EntityDataSortOrder.Direction.DESC), false); + + var entityFields = Arrays.asList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime")); + var latestValues = Arrays.asList(new EntityKey(EntityKeyType.TIME_SERIES, "state")); + KeyFilter nameFilter = new KeyFilter(); + nameFilter.setKey(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + var predicate = new StringFilterPredicate(); + predicate.setIgnoreCase(false); + predicate.setOperation(StringFilterPredicate.StringOperation.CONTAINS); + predicate.setValue(new FilterPredicateValue<>("LoRa-")); + nameFilter.setPredicate(predicate); + nameFilter.setValueType(EntityKeyValueType.STRING); + + return new EntityDataQuery(filter, pageLink, entityFields, latestValues, Arrays.asList(nameFilter)); + } + +} diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/EdgeSearchQueryFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EdgeSearchQueryFilterTest.java new file mode 100644 index 0000000000..f0911570ea --- /dev/null +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EdgeSearchQueryFilterTest.java @@ -0,0 +1,116 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.query.QueryResult; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.EdgeSearchQueryFilter; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.StringFilterPredicate; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +public class EdgeSearchQueryFilterTest extends AbstractEDQTest { + + @Before + public void setUp() { + } + + @Test + public void testFindDevicesManagesByTenant() { + UUID edge1 = createEdge("E1"); + UUID edge2 = createEdge("E2"); + UUID device1 = createDevice("D1"); + UUID device2 = createDevice("D2"); + UUID device3 = createDevice("D3"); + + createRelation(EntityType.EDGE, edge1, EntityType.DEVICE, device1, "Manages"); + createRelation(EntityType.EDGE, edge2, EntityType.DEVICE, device2, "Manages"); + createRelation(EntityType.EDGE, edge2, EntityType.DEVICE, device3, "Manages"); + + // find devices managed by edge + PageData relationsResult = findData(null, new DeviceId(device1), + EntitySearchDirection.TO, "Manages", 2, false, Arrays.asList("default")); + Assert.assertEquals(1, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, edge1)); + + // find devices managed by edge with non-existing type + relationsResult = findData(null, new DeviceId(device1), + EntitySearchDirection.TO, "Manages", 1, false, Arrays.asList("non-existing type")); + Assert.assertEquals(0, relationsResult.getData().size()); + + // find all entity views last level only, level = 2 + relationsResult = findData(null, new DeviceId(device1), + EntitySearchDirection.TO, "Manages", 2, true, Arrays.asList("default")); + Assert.assertEquals(1, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, edge1)); + } + + @Test + public void testFindCustomerEdges() { + UUID edge1 = createEdge(customerId, "E1"); + UUID edge2 = createEdge(customerId, "E2"); + createRelation(EntityType.CUSTOMER, customerId.getId(), EntityType.EDGE, edge1, "Manages"); + createRelation(EntityType.CUSTOMER, customerId.getId(), EntityType.EDGE, edge2, "Manages"); + + // find all edges managed by customer + PageData relationsResult = findData(customerId, customerId, + EntitySearchDirection.FROM, "Manages", 2, false, Arrays.asList("default")); + Assert.assertEquals(2, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, edge1)); + Assert.assertTrue(checkContains(relationsResult, edge2)); + + // find all edges managed by customer with non-existing type + relationsResult = findData(customerId, customerId, + EntitySearchDirection.FROM, "Manages", 2, false, Arrays.asList("non existing")); + Assert.assertEquals(0, relationsResult.getData().size()); + + // find all entity views with other customer + relationsResult = findData(new CustomerId(UUID.randomUUID()), customerId, + EntitySearchDirection.FROM, "Manages", 2, false, Arrays.asList("default")); + Assert.assertEquals(0, relationsResult.getData().size()); + } + + private PageData findData(CustomerId customerId, EntityId rootId, + EntitySearchDirection direction, String relationType, int maxLevel, boolean lastLevelOnly, List edgeTypes) { + EdgeSearchQueryFilter filter = new EdgeSearchQueryFilter(); + filter.setRootEntity(rootId); + filter.setDirection(direction); + filter.setRelationType(relationType); + filter.setEdgeTypes(edgeTypes); + filter.setFetchLastLevelOnly(lastLevelOnly); + filter.setMaxLevel(maxLevel); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, null); + List keyFiltersEqualString = createStringKeyFilters("name", EntityKeyType.ENTITY_FIELD, StringFilterPredicate.StringOperation.STARTS_WITH, "E"); + EntityDataQuery query = new EntityDataQuery(filter, pageLink, Collections.emptyList(), Collections.emptyList(), keyFiltersEqualString); + return repository.findEntityDataByQuery(tenantId, customerId, query, false); + } + +} diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/EdgeTypeFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EdgeTypeFilterTest.java new file mode 100644 index 0000000000..1d7bbef011 --- /dev/null +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EdgeTypeFilterTest.java @@ -0,0 +1,176 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edqs.LatestTsKv; +import org.thingsboard.server.common.data.edqs.query.QueryResult; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.query.EdgeTypeFilter; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityKeyValueType; +import org.thingsboard.server.common.data.query.FilterPredicateValue; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.StringFilterPredicate; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +public class EdgeTypeFilterTest extends AbstractEDQTest { + + private Edge edge; + private Edge edge2; + private Edge edge3; + + + @Before + public void setUp() { + edge = buildEdge("default", "Edge 1"); + edge2 = buildEdge("default", "Edge 2"); + edge3 = buildEdge("edge v2", "Edge 3"); + addOrUpdate(EntityType.EDGE, edge); + addOrUpdate(EntityType.EDGE, edge2); + addOrUpdate(EntityType.EDGE, edge3); + } + + @After + public void tearDown() { + } + + @Test + public void testFindTenantEdges() { + // find edges with type "default" + var result = repository.findEntityDataByQuery(tenantId, null, getEdgeTypeQuery(Collections.singletonList("default"), null, null), false); + + Assert.assertEquals(2, result.getTotalElements()); + Optional firstView = result.getData().stream().filter(queryResult -> queryResult.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue().equals("Edge 1")).findFirst(); + assertThat(firstView).isPresent(); + assertThat(firstView.get().getEntityId()).isEqualTo(edge.getId()); + assertThat(firstView.get().getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()).isEqualTo(String.valueOf(edge.getCreatedTime())); + + // find edges with types "default" and "edge v2" + result = repository.findEntityDataByQuery(tenantId, null, getEdgeTypeQuery(Arrays.asList("default", "edge v2"), null, null), false); + + Assert.assertEquals(3, result.getTotalElements()); + Optional thirdView = result.getData().stream().filter(queryResult -> queryResult.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue().equals("Edge 3")).findFirst(); + assertThat(thirdView).isPresent(); + assertThat(thirdView.get().getEntityId()).isEqualTo(edge3.getId()); + assertThat(thirdView.get().getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()).isEqualTo(String.valueOf(edge.getCreatedTime())); + + // find entity view with type "day 3" + result = repository.findEntityDataByQuery(tenantId, null, getEdgeTypeQuery(List.of("edge v3"), null, null), false); + Assert.assertEquals(0, result.getTotalElements()); + + // find entity view with name "%Edge%" + result = repository.findEntityDataByQuery(tenantId, null, getEdgeTypeQuery(List.of("default"), "%Edge%", null), false); + Assert.assertEquals(2, result.getTotalElements()); + + // find entity view with name "Edge 1" + result = repository.findEntityDataByQuery(tenantId, null, getEdgeTypeQuery(List.of("default"), "Edge 1", null), false); + Assert.assertEquals(1, result.getTotalElements()); + + // find entity view with name "%Edge 4%" + result = repository.findEntityDataByQuery(tenantId, null, getEdgeTypeQuery(List.of("default"), "%Edge 4%", null), false); + Assert.assertEquals(0, result.getTotalElements()); + + // find entity view with key filter: name contains "Edge" + KeyFilter containsNameFilter = getEntityViewNameKeyFilter(StringFilterPredicate.StringOperation.CONTAINS, "Edge", true); + result = repository.findEntityDataByQuery(tenantId, null, getEdgeTypeQuery(List.of("default"), null, List.of(containsNameFilter)), false); + Assert.assertEquals(2, result.getTotalElements()); + + // find entity view with key filter: name starts with "edge" and matches case + KeyFilter startsWithNameFilter = getEntityViewNameKeyFilter(StringFilterPredicate.StringOperation.STARTS_WITH, "edge", false); + result = repository.findEntityDataByQuery(tenantId, null, getEdgeTypeQuery(List.of("default"), null, List.of(startsWithNameFilter)), false); + Assert.assertEquals(0, result.getTotalElements()); + } + + @Test + public void testFindCustomerEdges() { + addOrUpdate(new LatestTsKv(edge.getId(), new BasicTsKvEntry(43, new StringDataEntry("state", "TEST")), 0L)); + + var result = repository.findEntityDataByQuery(tenantId, customerId, getEdgeTypeQuery(List.of("default"), null, null), false); + Assert.assertEquals(0, result.getTotalElements()); + + edge.setCustomerId(customerId); + edge2.setCustomerId(customerId); + edge3.setCustomerId(customerId); + addOrUpdate(EntityType.EDGE, edge); + addOrUpdate(EntityType.EDGE, edge2); + addOrUpdate(EntityType.EDGE, edge3); + + result = repository.findEntityDataByQuery(tenantId, customerId, getEdgeTypeQuery(List.of("default"), null, null), false); + + Assert.assertEquals(2, result.getTotalElements()); + Optional firstView = result.getData().stream().filter(queryResult -> queryResult.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue().equals("Edge 1")).findFirst(); + assertThat(firstView).isPresent(); + assertThat(firstView.get().getEntityId()).isEqualTo(edge.getId()); + assertThat(firstView.get().getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()).isEqualTo(String.valueOf(edge.getCreatedTime())); + + result = repository.findEntityDataByQuery(tenantId, customerId, getEdgeTypeQuery(List.of("edge v3"), null, null), false); + Assert.assertEquals(0, result.getTotalElements()); + } + + private Edge buildEdge(String type, String name) { + Edge edge = new Edge(); + edge.setId(new EdgeId(UUID.randomUUID())); + edge.setTenantId(tenantId); + edge.setType(type); + edge.setName(name); + edge.setCreatedTime(42L); + return edge; + } + + private static EntityDataQuery getEdgeTypeQuery(List edgeTypes, String edgeNameFilter, List keyFilters) { + EdgeTypeFilter filter = new EdgeTypeFilter(); + filter.setEdgeTypes(edgeTypes); + filter.setEdgeNameFilter(edgeNameFilter); + var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime"), EntityDataSortOrder.Direction.DESC), false); + + var entityFields = Arrays.asList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime")); + var latestValues = Arrays.asList(new EntityKey(EntityKeyType.TIME_SERIES, "state")); + + return new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); + } + + private static KeyFilter getEntityViewNameKeyFilter(StringFilterPredicate.StringOperation operation, String predicateValue, boolean ignoreCase) { + KeyFilter nameFilter = new KeyFilter(); + nameFilter.setKey(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + var predicate = new StringFilterPredicate(); + predicate.setIgnoreCase(ignoreCase); + predicate.setOperation(operation); + predicate.setValue(new FilterPredicateValue<>(predicateValue)); + nameFilter.setPredicate(predicate); + nameFilter.setValueType(EntityKeyValueType.STRING); + return nameFilter; + } + +} diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityListFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityListFilterTest.java new file mode 100644 index 0000000000..dc48bd5438 --- /dev/null +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityListFilterTest.java @@ -0,0 +1,143 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.LatestTsKv; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityKeyValueType; +import org.thingsboard.server.common.data.query.EntityListFilter; +import org.thingsboard.server.common.data.query.FilterPredicateValue; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.StringFilterPredicate; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +public class EntityListFilterTest extends AbstractEDQTest { + + private Device device; + private Device device2; + private Device device3; + + + @Before + public void setUp() { + device = buildDevice("LoRa-1"); + device2 = buildDevice("LoRa-2"); + device3 = buildDevice("Parking-Sensor-1"); + + addOrUpdate(EntityType.DEVICE, device); + addOrUpdate(EntityType.DEVICE, device2); + addOrUpdate(EntityType.DEVICE, device3); + + addOrUpdate(new LatestTsKv(device.getId(), new BasicTsKvEntry(43, new StringDataEntry("state", "enabled")), 0L)); + addOrUpdate(new LatestTsKv(device2.getId(), new BasicTsKvEntry(43, new StringDataEntry("state", "disabled")), 0L)); + addOrUpdate(new LatestTsKv(device3.getId(), new BasicTsKvEntry(43, new BooleanDataEntry("free", true)), 0L)); + } + + @After + public void tearDown() { + } + + @Test + public void testFindTenantDevice() { + // get entity list + var result = repository.findEntityDataByQuery(tenantId, null, getEntityListDataQuery(EntityType.DEVICE, List.of(device.getId().getId().toString())), false); + + Assert.assertEquals(1, result.getTotalElements()); + var first = result.getData().get(0); + Assert.assertEquals(device.getId(), first.getEntityId()); + Assert.assertEquals("LoRa-1", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()); + Assert.assertEquals("42", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()); + Assert.assertEquals("enabled", first.getLatest().get(EntityKeyType.TIME_SERIES).get("state").getValue()); + + result = repository.findEntityDataByQuery(tenantId, null, getEntityListDataQuery(EntityType.DEVICE,List.of(device.getId().getId().toString(), device2.getId().getId().toString())), false); + Assert.assertEquals(2, result.getTotalElements()); + + result = repository.findEntityDataByQuery(tenantId, null, getEntityListDataQuery(EntityType.DEVICE, List.of(UUID.randomUUID().toString())), false); + Assert.assertEquals(0, result.getTotalElements()); + } + + @Test + public void testFindCustomerDevice() { + var result = repository.findEntityDataByQuery(tenantId, customerId, getEntityListDataQuery(EntityType.DEVICE, List.of(device.getId().getId().toString())), false); + Assert.assertEquals(0, result.getTotalElements()); + + device.setCustomerId(customerId); + addOrUpdate(EntityType.DEVICE, device); + + result = repository.findEntityDataByQuery(tenantId, customerId, getEntityListDataQuery(EntityType.DEVICE, List.of(device.getId().getId().toString())), false); + + Assert.assertEquals(1, result.getTotalElements()); + var first = result.getData().get(0); + Assert.assertEquals(device.getId(), first.getEntityId()); + Assert.assertEquals("LoRa-1", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()); + Assert.assertEquals("42", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()); + Assert.assertEquals("enabled", first.getLatest().get(EntityKeyType.TIME_SERIES).get("state").getValue()); + } + + private Device buildDevice(String name) { + Device device = new Device(); + device.setId(new DeviceId(UUID.randomUUID())); + device.setTenantId(tenantId); + device.setName(name); + device.setCreatedTime(42L); + device.setDeviceProfileId(new DeviceProfileId(defaultDeviceProfileId)); + return device; + } + + private static EntityDataQuery getEntityListDataQuery(EntityType entityType, List ids) { + EntityListFilter filter = new EntityListFilter(); + filter.setEntityType(entityType); + filter.setEntityList(ids); + var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.TIME_SERIES, "state"), EntityDataSortOrder.Direction.DESC), false); + + var entityFields = Arrays.asList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime")); + var latestValues = Arrays.asList(new EntityKey(EntityKeyType.TIME_SERIES, "state")); + KeyFilter nameFilter = getNameKeyFilter(StringFilterPredicate.StringOperation.CONTAINS, "LoRa-"); + + return new EntityDataQuery(filter, pageLink, entityFields, latestValues, Arrays.asList(nameFilter)); + } + + private static KeyFilter getNameKeyFilter(StringFilterPredicate.StringOperation operation, String value) { + KeyFilter nameFilter = new KeyFilter(); + nameFilter.setKey(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + var predicate = new StringFilterPredicate(); + predicate.setIgnoreCase(false); + predicate.setOperation(operation); + predicate.setValue(new FilterPredicateValue<>(value)); + nameFilter.setPredicate(predicate); + nameFilter.setValueType(EntityKeyValueType.STRING); + return nameFilter; + } + +} diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityNameFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityNameFilterTest.java new file mode 100644 index 0000000000..3e47c245ef --- /dev/null +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityNameFilterTest.java @@ -0,0 +1,131 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.LatestTsKv; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityKeyValueType; +import org.thingsboard.server.common.data.query.EntityNameFilter; +import org.thingsboard.server.common.data.query.FilterPredicateValue; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.StringFilterPredicate; + +import java.util.Arrays; +import java.util.UUID; + +public class EntityNameFilterTest extends AbstractEDQTest { + + @Before + public void setUp() { + } + + @After + public void tearDown() { + } + + @Test + public void testFindTenantDevice() { + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + Device device = new Device(); + device.setId(deviceId); + device.setTenantId(tenantId); + device.setName("LoRa-1"); + device.setCreatedTime(42L); + device.setDeviceProfileId(new DeviceProfileId(defaultDeviceProfileId)); + addOrUpdate(EntityType.DEVICE, device); + + var result = repository.findEntityDataByQuery(tenantId, null, getDeviceNameQuery("LoRa"), false); + + Assert.assertEquals(1, result.getTotalElements()); + var first = result.getData().get(0); + Assert.assertEquals(deviceId, first.getEntityId()); + Assert.assertEquals("LoRa-1", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()); + Assert.assertEquals("42", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()); + + result = repository.findEntityDataByQuery(tenantId, null, getDeviceNameQuery("Not LoRa"), false); + Assert.assertEquals(0, result.getTotalElements()); + + device.setCustomerId(customerId); + addOrUpdate(EntityType.DEVICE, device); + + result = repository.findEntityDataByQuery(tenantId, null, getDeviceNameQuery("%1"), false); + Assert.assertEquals(1, result.getTotalElements()); + result = repository.findEntityDataByQuery(tenantId, null, getDeviceNameQuery("L%"), false); + Assert.assertEquals(1, result.getTotalElements()); + } + + @Test + public void testFindCustomerDevice() { + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + Device device = new Device(); + device.setId(deviceId); + device.setTenantId(tenantId); + device.setName("LoRa-1"); + device.setCreatedTime(42L); + device.setDeviceProfileId(new DeviceProfileId(defaultDeviceProfileId)); + addOrUpdate(EntityType.DEVICE, device); + addOrUpdate(new LatestTsKv(deviceId, new BasicTsKvEntry(43, new StringDataEntry("state", "TEST")), 0L)); + + var result = repository.findEntityDataByQuery(tenantId, customerId, getDeviceNameQuery("LoRa"), false); + Assert.assertEquals(0, result.getTotalElements()); + + device.setCustomerId(customerId); + addOrUpdate(EntityType.DEVICE, device); + + result = repository.findEntityDataByQuery(tenantId, customerId, getDeviceNameQuery("LoRa"), false); + + Assert.assertEquals(1, result.getTotalElements()); + var first = result.getData().get(0); + Assert.assertEquals(deviceId, first.getEntityId()); + Assert.assertEquals("LoRa-1", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()); + Assert.assertEquals("42", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()); + } + + private static EntityDataQuery getDeviceNameQuery(String entityNameFilter) { + EntityNameFilter filter = new EntityNameFilter(); + filter.setEntityType(EntityType.DEVICE); + filter.setEntityNameFilter(entityNameFilter); + var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.TIME_SERIES, "state"), EntityDataSortOrder.Direction.DESC), false); + + var entityFields = Arrays.asList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime")); + var latestValues = Arrays.asList(new EntityKey(EntityKeyType.TIME_SERIES, "state")); + KeyFilter nameFilter = new KeyFilter(); + nameFilter.setKey(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + var predicate = new StringFilterPredicate(); + predicate.setIgnoreCase(false); + predicate.setOperation(StringFilterPredicate.StringOperation.CONTAINS); + predicate.setValue(new FilterPredicateValue<>("LoRa-")); + nameFilter.setPredicate(predicate); + nameFilter.setValueType(EntityKeyValueType.STRING); + + return new EntityDataQuery(filter, pageLink, entityFields, latestValues, Arrays.asList(nameFilter)); + } + +} diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityTypeFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityTypeFilterTest.java new file mode 100644 index 0000000000..8fab142dc7 --- /dev/null +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityTypeFilterTest.java @@ -0,0 +1,146 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.LatestTsKv; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityKeyValueType; +import org.thingsboard.server.common.data.query.EntityTypeFilter; +import org.thingsboard.server.common.data.query.FilterPredicateValue; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.StringFilterPredicate; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +public class EntityTypeFilterTest extends AbstractEDQTest { + + private Device device; + private Device device2; + private Device device3; + + @Before + public void setUp() { + device = buildDevice("LoRa-1"); + device2 = buildDevice("LoRa-2"); + device3 = buildDevice("Parking-Sensor-1"); + addOrUpdate(EntityType.DEVICE, device); + addOrUpdate(EntityType.DEVICE, device2); + addOrUpdate(EntityType.DEVICE, device3); + addOrUpdate(new LatestTsKv(device.getId(), new BasicTsKvEntry(43, new StringDataEntry("state", "enabled")), 0L)); + addOrUpdate(new LatestTsKv(device2.getId(), new BasicTsKvEntry(43, new StringDataEntry("state", "disabled")), 0L)); + addOrUpdate(new LatestTsKv(device3.getId(), new BasicTsKvEntry(43, new BooleanDataEntry("free", true)), 0L)); + } + + @After + public void tearDown() { + } + + @Test + public void testFindTenantDeviceEntities() { + // find all tenant devices + var result = repository.findEntityDataByQuery(tenantId, null, getEntityTypeQuery(EntityType.DEVICE, null), false); + + Assert.assertEquals(3, result.getTotalElements()); + var first = result.getData().stream().filter(queryResult -> queryResult.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue().equals("LoRa-1")).findAny(); + assertThat(first).isPresent(); + assertThat(first.get().getEntityId()).isEqualTo(device.getId()); + assertThat(first.get().getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()).isEqualTo(String.valueOf(device.getCreatedTime())); + assertThat(first.get().getLatest().get(EntityKeyType.TIME_SERIES).get("state").getValue()).isEqualTo("enabled"); + + // find all tenant devices with filter by name + KeyFilter keyFilter = getDeviceNameKeyFilter(StringFilterPredicate.StringOperation.CONTAINS, "Lora", true); + result = repository.findEntityDataByQuery(tenantId, null, getEntityTypeQuery(EntityType.DEVICE, List.of(keyFilter)), false); + Assert.assertEquals(2, result.getTotalElements()); + + // find asset entities + result = repository.findEntityDataByQuery(tenantId, null, getEntityTypeQuery(EntityType.ASSET, null), false); + Assert.assertEquals(0, result.getTotalElements()); + } + + @Test + public void testFindCustomerDeviceEntities() { + var result = repository.findEntityDataByQuery(tenantId, customerId, getEntityTypeQuery(EntityType.DEVICE, null), false); + Assert.assertEquals(0, result.getTotalElements()); + + device.setCustomerId(customerId); + addOrUpdate(EntityType.DEVICE, device); + + result = repository.findEntityDataByQuery(tenantId, customerId, getEntityTypeQuery(EntityType.DEVICE, null), false); + + Assert.assertEquals(1, result.getTotalElements()); + var first = result.getData().get(0); + Assert.assertEquals(device.getId(), first.getEntityId()); + Assert.assertEquals("LoRa-1", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()); + Assert.assertEquals("42", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()); + Assert.assertEquals("enabled", first.getLatest().get(EntityKeyType.TIME_SERIES).get("state").getValue()); + + result = repository.findEntityDataByQuery(tenantId, customerId, getEntityTypeQuery(EntityType.ASSET, null), false); + Assert.assertEquals(0, result.getTotalElements()); + } + + private Device buildDevice(String name) { + Device device = new Device(); + device.setId(new DeviceId(UUID.randomUUID())); + device.setTenantId(tenantId); + device.setName(name); + device.setCreatedTime(42L); + device.setDeviceProfileId(new DeviceProfileId(defaultDeviceProfileId)); + return device; + } + + private static EntityDataQuery getEntityTypeQuery(EntityType entityType, List keyFilters) { + EntityTypeFilter filter = new EntityTypeFilter(); + filter.setEntityType(entityType); + var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime"), EntityDataSortOrder.Direction.DESC), false); + + var entityFields = Arrays.asList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime")); + var latestValues = Arrays.asList(new EntityKey(EntityKeyType.TIME_SERIES, "state")); + + return new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); + } + + private static KeyFilter getDeviceNameKeyFilter(StringFilterPredicate.StringOperation operation, String predicateValue, boolean ignoreCase) { + KeyFilter nameFilter = new KeyFilter(); + nameFilter.setKey(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + var predicate = new StringFilterPredicate(); + predicate.setIgnoreCase(ignoreCase); + predicate.setOperation(operation); + predicate.setValue(new FilterPredicateValue<>(predicateValue)); + nameFilter.setPredicate(predicate); + nameFilter.setValueType(EntityKeyValueType.STRING); + return nameFilter; + } + +} diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityViewSearchQueryFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityViewSearchQueryFilterTest.java new file mode 100644 index 0000000000..fb32759045 --- /dev/null +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityViewSearchQueryFilterTest.java @@ -0,0 +1,130 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.query.QueryResult; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityViewSearchQueryFilter; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.StringFilterPredicate; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +public class EntityViewSearchQueryFilterTest extends AbstractEDQTest { + + @Before + public void setUp() { + } + + @Test + public void testFindTenantEntityViews() { + UUID asset1 = createAsset("A1"); + UUID device1 = createDevice("D1"); + UUID device2 = createDevice("D2"); + UUID deviceView1 = createView("V1"); + UUID deviceView2 = createView("V2"); + + createRelation(EntityType.ASSET, asset1, EntityType.DEVICE, device1, "Contains"); + createRelation(EntityType.ASSET, asset1, EntityType.DEVICE, device2, "Contains"); + createRelation(EntityType.DEVICE, device1, EntityType.ENTITY_VIEW, deviceView1, "Contains"); + createRelation(EntityType.DEVICE, device2, EntityType.ENTITY_VIEW, deviceView2, "Contains"); + + // find all entity views of asset A1 + PageData relationsResult = findData(null, new AssetId(asset1), + EntitySearchDirection.FROM, "Contains", 2, false, Arrays.asList("default")); + Assert.assertEquals(2, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, deviceView1)); + Assert.assertTrue(checkContains(relationsResult, deviceView2)); + + // find all entity views with max level = 1 + relationsResult = findData(null, new AssetId(asset1), + EntitySearchDirection.FROM, "Contains", 1, false, Arrays.asList("default")); + Assert.assertEquals(0, relationsResult.getData().size()); + + // find all entity views with type "day 1" + relationsResult = findData(null, new AssetId(asset1), + EntitySearchDirection.FROM, "Contains", 1, false, Arrays.asList("day 1")); + Assert.assertEquals(0, relationsResult.getData().size()); + + // find all entity views last level only, level = 2 + relationsResult = findData(null, new AssetId(asset1), + EntitySearchDirection.FROM, "Contains", 2, true, Arrays.asList("default")); + Assert.assertEquals(2, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, deviceView1)); + Assert.assertTrue(checkContains(relationsResult, deviceView2)); + } + + @Test + public void testFindCustomerDevices() { + UUID asset1 = createAsset(customerId.getId(), defaultAssetProfileId, "A1"); + UUID device1 = createDevice(customerId.getId(), defaultDeviceProfileId, "D1"); + UUID device2 = createDevice(customerId.getId(), defaultDeviceProfileId, "D2"); + UUID deviceView1 = createView(customerId.getId(), "day 1", "V1"); + UUID deviceView2 = createView(customerId.getId(), "day 1", "V2"); + + createRelation(EntityType.ASSET, asset1, EntityType.DEVICE, device1, "Contains"); + createRelation(EntityType.ASSET, asset1, EntityType.DEVICE, device2, "Contains"); + createRelation(EntityType.DEVICE, device1, EntityType.ENTITY_VIEW, deviceView1, "Contains"); + createRelation(EntityType.DEVICE, device2, EntityType.ENTITY_VIEW, deviceView2, "Contains"); + + // find all entity views of type "day 1" + PageData relationsResult = findData(customerId, new AssetId(asset1), + EntitySearchDirection.FROM, "Contains", 2, false, Arrays.asList("day 1")); + Assert.assertEquals(2, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, deviceView1)); + Assert.assertTrue(checkContains(relationsResult, deviceView2)); + + // find all entity views of type "day 2" + relationsResult = findData(customerId, new AssetId(asset1), + EntitySearchDirection.FROM, "Contains", 2, false, Arrays.asList("day 2")); + Assert.assertEquals(0, relationsResult.getData().size()); + + // find all entity views with other customer + relationsResult = findData(new CustomerId(UUID.randomUUID()), new AssetId(asset1), + EntitySearchDirection.FROM, "Contains", 2, false, Arrays.asList("thermostat")); + Assert.assertEquals(0, relationsResult.getData().size()); + } + + private PageData findData(CustomerId customerId, EntityId rootId, + EntitySearchDirection direction, String relationType, int maxLevel, boolean lastLevelOnly, List entityViewTypes) { + EntityViewSearchQueryFilter filter = new EntityViewSearchQueryFilter(); + filter.setRootEntity(rootId); + filter.setDirection(direction); + filter.setRelationType(relationType); + filter.setEntityViewTypes(entityViewTypes); + filter.setFetchLastLevelOnly(lastLevelOnly); + filter.setMaxLevel(maxLevel); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, null); + List keyFiltersEqualString = createStringKeyFilters("name", EntityKeyType.ENTITY_FIELD, StringFilterPredicate.StringOperation.STARTS_WITH, "V"); + EntityDataQuery query = new EntityDataQuery(filter, pageLink, Collections.emptyList(), Collections.emptyList(), keyFiltersEqualString); + return repository.findEntityDataByQuery(tenantId, customerId, query, false); + } + +} diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityViewTypeFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityViewTypeFilterTest.java new file mode 100644 index 0000000000..bf1d329127 --- /dev/null +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityViewTypeFilterTest.java @@ -0,0 +1,176 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.edqs.LatestTsKv; +import org.thingsboard.server.common.data.edqs.query.QueryResult; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityKeyValueType; +import org.thingsboard.server.common.data.query.EntityViewTypeFilter; +import org.thingsboard.server.common.data.query.FilterPredicateValue; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.StringFilterPredicate; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +public class EntityViewTypeFilterTest extends AbstractEDQTest { + + private EntityView entityView; + private EntityView entityView2; + private EntityView entityView3; + + + @Before + public void setUp() { + entityView = buildEntityView("day 1", "day 1 lora 1 view"); + entityView2 = buildEntityView("day 1", "day 1 lora 2 view"); + entityView3 = buildEntityView("day 2", "day 2 lora 1 view"); + addOrUpdate(EntityType.ENTITY_VIEW, entityView); + addOrUpdate(EntityType.ENTITY_VIEW, entityView2); + addOrUpdate(EntityType.ENTITY_VIEW, entityView3); + } + + @After + public void tearDown() { + } + + @Test + public void testFindTenantEntityView() { + // find entity view with type "day 1" + var result = repository.findEntityDataByQuery(tenantId, null, getEntityViewTypeQuery(Collections.singletonList("day 1"), null, null), false); + + Assert.assertEquals(2, result.getTotalElements()); + Optional firstView = result.getData().stream().filter(queryResult -> queryResult.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue().equals("day 1 lora 1 view")).findFirst(); + assertThat(firstView).isPresent(); + assertThat(firstView.get().getEntityId()).isEqualTo(entityView.getId()); + assertThat(firstView.get().getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()).isEqualTo(String.valueOf(entityView.getCreatedTime())); + + // find entity view with types "day 1" and "day 2" + result = repository.findEntityDataByQuery(tenantId, null, getEntityViewTypeQuery(Arrays.asList("day 1", "day 2"), null, null), false); + + Assert.assertEquals(3, result.getTotalElements()); + Optional thirdView = result.getData().stream().filter(queryResult -> queryResult.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue().equals("day 2 lora 1 view")).findFirst(); + assertThat(thirdView).isPresent(); + assertThat(thirdView.get().getEntityId()).isEqualTo(entityView3.getId()); + assertThat(thirdView.get().getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()).isEqualTo(String.valueOf(entityView.getCreatedTime())); + + // find entity view with type "day 3" + result = repository.findEntityDataByQuery(tenantId, null, getEntityViewTypeQuery(Collections.singletonList("day 3"), null, null), false); + Assert.assertEquals(0, result.getTotalElements()); + + // find entity view with name "%Lora%" + result = repository.findEntityDataByQuery(tenantId, null, getEntityViewTypeQuery(Collections.singletonList("day 1"), "%day 1 lora%", null), false); + Assert.assertEquals(2, result.getTotalElements()); + + // find entity view with name "Lora 1 device view" + result = repository.findEntityDataByQuery(tenantId, null, getEntityViewTypeQuery(Collections.singletonList("day 1"), "day 1 lora 1 view", null), false); + Assert.assertEquals(1, result.getTotalElements()); + + // find entity view with name "%Parking sensor%" + result = repository.findEntityDataByQuery(tenantId, null, getEntityViewTypeQuery(Collections.singletonList("day 1"), "%day 3 lora%", null), false); + Assert.assertEquals(0, result.getTotalElements()); + + // find entity view with key filter: name contains "Lora" + KeyFilter containsNameFilter = getEntityViewNameKeyFilter(StringFilterPredicate.StringOperation.CONTAINS, "Lora", true); + result = repository.findEntityDataByQuery(tenantId, null, getEntityViewTypeQuery(Collections.singletonList("day 1"), null, Arrays.asList(containsNameFilter)), false); + Assert.assertEquals(2, result.getTotalElements()); + + // find entity view with key filter: name starts with "lora" and matches case + KeyFilter startsWithNameFilter = getEntityViewNameKeyFilter(StringFilterPredicate.StringOperation.STARTS_WITH, "lora", false); + result = repository.findEntityDataByQuery(tenantId, null, getEntityViewTypeQuery(Collections.singletonList("day 1"), null, Arrays.asList(startsWithNameFilter)), false); + Assert.assertEquals(0, result.getTotalElements()); + } + + @Test + public void testFindCustomerEntityView() { + addOrUpdate(new LatestTsKv(entityView.getId(), new BasicTsKvEntry(43, new StringDataEntry("state", "TEST")), 0L)); + + var result = repository.findEntityDataByQuery(tenantId, customerId, getEntityViewTypeQuery(Collections.singletonList("day 1"), null, null), false); + Assert.assertEquals(0, result.getTotalElements()); + + entityView.setCustomerId(customerId); + entityView2.setCustomerId(customerId); + entityView3.setCustomerId(customerId); + addOrUpdate(EntityType.ENTITY_VIEW, entityView); + addOrUpdate(EntityType.ENTITY_VIEW, entityView2); + addOrUpdate(EntityType.ENTITY_VIEW, entityView3); + + result = repository.findEntityDataByQuery(tenantId, customerId, getEntityViewTypeQuery(Collections.singletonList("day 1"), null, null), false); + + Assert.assertEquals(2, result.getTotalElements()); + Optional firstView = result.getData().stream().filter(queryResult -> queryResult.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue().equals("day 1 lora 1 view")).findFirst(); + assertThat(firstView).isPresent(); + assertThat(firstView.get().getEntityId()).isEqualTo(entityView.getId()); + assertThat(firstView.get().getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()).isEqualTo(String.valueOf(entityView.getCreatedTime())); + + result = repository.findEntityDataByQuery(tenantId, customerId, getEntityViewTypeQuery(Collections.singletonList("day 3"), null, null), false); + Assert.assertEquals(0, result.getTotalElements()); + } + + private EntityView buildEntityView(String type, String name) { + EntityView entityView = new EntityView(); + entityView.setId(new EntityViewId(UUID.randomUUID())); + entityView.setTenantId(tenantId); + entityView.setType(type); + entityView.setName(name); + entityView.setCreatedTime(42L); + return entityView; + } + + private static EntityDataQuery getEntityViewTypeQuery(List assetTypes, String entityViewNameFilter, List keyFilters) { + EntityViewTypeFilter filter = new EntityViewTypeFilter(); + filter.setEntityViewTypes(assetTypes); + filter.setEntityViewNameFilter(entityViewNameFilter); + var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime"), EntityDataSortOrder.Direction.DESC), false); + + var entityFields = Arrays.asList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime")); + var latestValues = Arrays.asList(new EntityKey(EntityKeyType.TIME_SERIES, "state")); + + return new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); + } + + private static KeyFilter getEntityViewNameKeyFilter(StringFilterPredicate.StringOperation operation, String predicateValue, boolean ignoreCase) { + KeyFilter nameFilter = new KeyFilter(); + nameFilter.setKey(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + var predicate = new StringFilterPredicate(); + predicate.setIgnoreCase(ignoreCase); + predicate.setOperation(operation); + predicate.setValue(new FilterPredicateValue<>(predicateValue)); + nameFilter.setPredicate(predicate); + nameFilter.setValueType(EntityKeyValueType.STRING); + return nameFilter; + } + +} diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/RelationsQueryFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RelationsQueryFilterTest.java new file mode 100644 index 0000000000..094d46f977 --- /dev/null +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RelationsQueryFilterTest.java @@ -0,0 +1,160 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.query.QueryResult; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.RelationsQueryFilter; +import org.thingsboard.server.common.data.query.StringFilterPredicate; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +public class RelationsQueryFilterTest extends AbstractEDQTest { + + @Before + public void setUp() { + } + + @Test + public void testFindTenantDevices() { + UUID ta1 = createAsset("T A1"); + UUID ta2 = createAsset("T A2"); + UUID da1 = createDevice("T D1"); + UUID da2 = createDevice(customerId, "T D2"); + UUID da3 = createDevice("NOT MATCHING D3"); + + // A1 --Contains--> A2, A1 --Contains--> D1. A1 --Manages--> D2. + createRelation(EntityType.ASSET, ta1, EntityType.ASSET, ta2, "Contains"); + createRelation(EntityType.ASSET, ta1, EntityType.DEVICE, da1, "Contains"); + createRelation(EntityType.ASSET, ta1, EntityType.DEVICE, da2, "Manages"); + createRelation(EntityType.ASSET, ta1, EntityType.DEVICE, da3, "Contains"); + + PageData relationsResult = filter(new AssetId(ta1), new RelationEntityTypeFilter("Contains", Arrays.asList(EntityType.DEVICE, EntityType.ASSET))); + Assert.assertEquals(2, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, ta2)); + Assert.assertTrue(checkContains(relationsResult, da1)); + + relationsResult = filter(new AssetId(ta1), new RelationEntityTypeFilter("Manages", Arrays.asList(EntityType.DEVICE, EntityType.ASSET))); + Assert.assertEquals(1, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, da2)); + } + + @Test + public void testFindTenantDevicesLastLevelOnly() { + UUID root = createAsset("T ROOT"); + + UUID ta1 = createAsset("T A1 NO MORE RELATIONS"); + UUID ta2 = createAsset("T A2"); + UUID da1 = createDevice("T D1"); + UUID da2 = createDevice(customerId, "T D2"); + UUID da3 = createDevice(customerId, "T D3"); + UUID da4 = createDevice(customerId, "T D4"); // Lvl 4 + + // ROOT --Contains--> A1, A2; A2 --Contains--> D1, D2; D2 --Contains--> D3. + createRelation(EntityType.ASSET, root, EntityType.ASSET, ta1, "Contains"); + createRelation(EntityType.ASSET, root, EntityType.ASSET, ta2, "Contains"); + createRelation(EntityType.ASSET, ta2, EntityType.DEVICE, da1, "Contains"); + createRelation(EntityType.ASSET, ta2, EntityType.DEVICE, da2, "Contains"); + createRelation(EntityType.ASSET, da2, EntityType.DEVICE, da3, "Contains"); + createRelation(EntityType.ASSET, da3, EntityType.DEVICE, da4, "Contains"); + + PageData relationsResult = filter(null, new AssetId(root), 1, true, + new RelationEntityTypeFilter("Contains", Arrays.asList(EntityType.DEVICE, EntityType.ASSET))); + Assert.assertEquals(2, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, ta1)); + Assert.assertTrue(checkContains(relationsResult, ta2)); + + relationsResult = filter(null, new AssetId(root), 2, true, + new RelationEntityTypeFilter("Contains", Arrays.asList(EntityType.DEVICE, EntityType.ASSET))); + Assert.assertEquals(3, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, ta1)); + Assert.assertTrue(checkContains(relationsResult, da1)); + Assert.assertTrue(checkContains(relationsResult, da2)); + + relationsResult = filter(null, new AssetId(root), 3, true, + new RelationEntityTypeFilter("Contains", Arrays.asList(EntityType.DEVICE, EntityType.ASSET))); + Assert.assertEquals(3, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, ta1)); + Assert.assertTrue(checkContains(relationsResult, da1)); + Assert.assertTrue(checkContains(relationsResult, da3)); + + relationsResult = filter(null, new AssetId(root), 4, true, + new RelationEntityTypeFilter("Contains", Arrays.asList(EntityType.DEVICE, EntityType.ASSET))); + Assert.assertEquals(3, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, ta1)); + Assert.assertTrue(checkContains(relationsResult, da1)); + Assert.assertTrue(checkContains(relationsResult, da4)); + + } + + @Test + public void testFindCustomerDevices() { + UUID ta1 = createAsset("T A1"); + UUID ta2 = createAsset("T A2"); + UUID da1 = createDevice(customerId, "T D1"); + UUID da2 = createDevice("T D2"); + + // A1 --Contains--> A2, A1 --Contains--> D1. A1 --Manages--> D2. + createRelation(EntityType.ASSET, ta1, EntityType.ASSET, ta2, "Contains"); + createRelation(EntityType.ASSET, ta1, EntityType.DEVICE, da1, "Contains"); + createRelation(EntityType.ASSET, ta1, EntityType.DEVICE, da2, "Manages"); + + PageData relationsResult = filter(customerId, new AssetId(ta1), new RelationEntityTypeFilter("Contains", Arrays.asList(EntityType.DEVICE, EntityType.ASSET))); + Assert.assertEquals(1, relationsResult.getData().size()); + Assert.assertTrue(checkContains(relationsResult, da1)); + + relationsResult = filter(customerId, new AssetId(ta1), new RelationEntityTypeFilter("Manages", Arrays.asList(EntityType.DEVICE, EntityType.ASSET))); + Assert.assertEquals(0, relationsResult.getData().size()); + } + + private PageData filter(EntityId rootId, RelationEntityTypeFilter... relationEntityTypeFilters) { + return filter(null, rootId, relationEntityTypeFilters); + } + + private PageData filter(CustomerId customerId, EntityId rootId, RelationEntityTypeFilter... relationEntityTypeFilters) { + return filter(customerId, rootId, 3, false, relationEntityTypeFilters); + } + + private PageData filter(CustomerId customerId, EntityId rootId, int maxLevel, boolean lastLevelOnly, RelationEntityTypeFilter... relationEntityTypeFilters) { + RelationsQueryFilter filter = new RelationsQueryFilter(); + filter.setRootEntity(rootId); + filter.setFilters(Arrays.asList(relationEntityTypeFilters)); + filter.setDirection(EntitySearchDirection.FROM); + filter.setFetchLastLevelOnly(lastLevelOnly); + filter.setMaxLevel(maxLevel); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, null); + List keyFiltersEqualString = createStringKeyFilters("name", EntityKeyType.ENTITY_FIELD, StringFilterPredicate.StringOperation.STARTS_WITH, "T"); + EntityDataQuery query = new EntityDataQuery(filter, pageLink, Collections.emptyList(), Collections.emptyList(), keyFiltersEqualString); + return repository.findEntityDataByQuery(tenantId, customerId, query, false); + } + +} diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java new file mode 100644 index 0000000000..6c7444c92a --- /dev/null +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java @@ -0,0 +1,434 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.edqs.fields.DeviceFields; +import org.thingsboard.server.common.data.edqs.fields.DeviceProfileFields; +import org.thingsboard.server.common.data.query.BooleanFilterPredicate; +import org.thingsboard.server.common.data.query.BooleanFilterPredicate.BooleanOperation; +import org.thingsboard.server.common.data.query.ComplexFilterPredicate; +import org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityKeyValueType; +import org.thingsboard.server.common.data.query.FilterPredicateValue; +import org.thingsboard.server.common.data.query.NumericFilterPredicate; +import org.thingsboard.server.common.data.query.NumericFilterPredicate.NumericOperation; +import org.thingsboard.server.common.data.query.StringFilterPredicate; +import org.thingsboard.server.common.data.query.StringFilterPredicate.StringOperation; +import org.thingsboard.server.edqs.data.DeviceData; +import org.thingsboard.server.edqs.data.EntityProfileData; +import org.thingsboard.server.edqs.data.dp.BoolDataPoint; +import org.thingsboard.server.edqs.data.dp.DoubleDataPoint; +import org.thingsboard.server.edqs.data.dp.LongDataPoint; +import org.thingsboard.server.edqs.data.dp.StringDataPoint; +import org.thingsboard.server.edqs.query.DataKey; +import org.thingsboard.server.edqs.query.EdqsFilter; +import org.thingsboard.server.edqs.util.RepositoryUtils; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + + +public class RepositoryUtilsTest { + + private static Stream deviceNameFilters() { + return Stream.of(Arguments.of(null, getNameFilter(StringOperation.STARTS_WITH, "lora"), true), + Arguments.of("loranet device 123", getNameFilter(StringOperation.STARTS_WITH, "lora"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.STARTS_WITH, "ra"), false), + Arguments.of("loranet 123", getNameFilter(StringOperation.ENDS_WITH, "123"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.ENDS_WITH, "device"), false), + Arguments.of("loranet 123", getNameFilter(StringOperation.EQUAL, "loranet 123"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.EQUAL, "loranet "), false), + Arguments.of("loranet 123", getNameFilter(StringOperation.NOT_EQUAL, "loranet"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.NOT_EQUAL, "loranet 123"), false), + Arguments.of("loranet 123", getNameFilter(StringOperation.CONTAINS, "loranet"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.CONTAINS, "loranet123"), false), + Arguments.of("loranet 123", getNameFilter(StringOperation.NOT_CONTAINS, "loranet123"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.NOT_CONTAINS, "loranet"), false), + Arguments.of("loranet 123", getNameFilter(StringOperation.IN, "loranet 123, loranet 124"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.IN, "loranet 125, loranet 126"), false), + Arguments.of("loranet 123", getNameFilter(StringOperation.NOT_IN, "loranet 125, loranet 126"), true), + Arguments.of("loranet 123", getNameFilter(StringOperation.NOT_IN, "loranet 123, loranet 126"), false) + ); + } + + @ParameterizedTest + @MethodSource("deviceNameFilters") + public void testFilterByDeviceName(String deviceName, EdqsFilter keyFilter, boolean result) { + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name(deviceName).build()); + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(keyFilter))).isEqualTo(result); + } + + private static Stream createdTimeFilters() { + return Stream.of(Arguments.of(1000, getCreatedTimeFilter(NumericOperation.EQUAL, 1000), true), + Arguments.of(1000, getCreatedTimeFilter(NumericOperation.EQUAL, 1001), false), + Arguments.of(1000, getCreatedTimeFilter(NumericOperation.NOT_EQUAL, 1000), false), + Arguments.of(1000, getCreatedTimeFilter(NumericOperation.NOT_EQUAL, 1001), true), + Arguments.of(1000, getCreatedTimeFilter(NumericOperation.GREATER, 999), true), + Arguments.of(1000, getCreatedTimeFilter(NumericOperation.GREATER, 1000), false), + Arguments.of(1000, getCreatedTimeFilter(NumericOperation.GREATER_OR_EQUAL, 1000), true), + Arguments.of(1000, getCreatedTimeFilter(NumericOperation.GREATER_OR_EQUAL, 1001), false), + Arguments.of(1000, getCreatedTimeFilter(NumericOperation.LESS, 1001), true), + Arguments.of(1000, getCreatedTimeFilter(NumericOperation.LESS, 1000), false), + Arguments.of(1000, getCreatedTimeFilter(NumericOperation.LESS_OR_EQUAL, 1000), true), + Arguments.of(1000, getCreatedTimeFilter(NumericOperation.LESS_OR_EQUAL, 999), false) + ); + } + + @ParameterizedTest + @MethodSource("createdTimeFilters") + public void testFilterDevicesByCreatedTime(long createdTime, EdqsFilter keyFilter, boolean result) { + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().createdTime(createdTime).build()); + + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(keyFilter))).isEqualTo(result); + } + + private static Stream deviceNameAndTypeFilter() { + return Stream.of( + Arguments.of("loranet 123", "thermostat", List.of(getNameFilter(StringOperation.STARTS_WITH, "lo"), getTypeFilter(StringOperation.EQUAL, "thermostat")), true), + Arguments.of("loranet 123", "thermostat", List.of(getNameFilter(StringOperation.STARTS_WITH, "net"), getTypeFilter(StringOperation.EQUAL, "thermostat")), false), + Arguments.of("loranet 123", "thermostat", List.of(getNameFilter(StringOperation.STARTS_WITH, "lo"), getTypeFilter(StringOperation.EQUAL, "sensor1")), false), + Arguments.of("loranet 123", "thermostat", List.of(getNameFilter(StringOperation.STARTS_WITH, "net"), getTypeFilter(StringOperation.EQUAL, "sensor1")), false)); + } + + @ParameterizedTest + @MethodSource("deviceNameAndTypeFilter") + public void testFilterByDeviceNameAndDeviceType(String deviceName, String deviceType, List keyFilters, boolean result) { + UUID deviceProfileId = UUID.randomUUID(); + EntityProfileData deviceProfile = new EntityProfileData(deviceProfileId, EntityType.DEVICE_PROFILE); + deviceProfile.setFields(DeviceProfileFields.builder().name(deviceType).build()); + + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name(deviceName).deviceProfileId(deviceProfileId).type(deviceType).build()); + + assertThat(RepositoryUtils.checkKeyFilters(deviceData, keyFilters)).isEqualTo(result); + } + + private static Stream deviceNameComplexFilters() { + return Stream.of(Arguments.of(null, List.of(getComplexComplexDeviceNameFilter(StringOperation.STARTS_WITH, "lo", ComplexOperation.AND, StringOperation.ENDS_WITH, "123")), true), + Arguments.of("loranet 123", List.of(getComplexComplexDeviceNameFilter(StringOperation.STARTS_WITH, "lo", ComplexOperation.AND, StringOperation.ENDS_WITH, "123")), true), + Arguments.of("loranet 123", List.of(getComplexComplexDeviceNameFilter(StringOperation.STARTS_WITH, "lo", ComplexOperation.AND, StringOperation.ENDS_WITH, "124")), false), + Arguments.of("loranet 123", List.of(getComplexComplexDeviceNameFilter(StringOperation.STARTS_WITH, "lo", ComplexOperation.OR, StringOperation.STARTS_WITH, "net")), true), + Arguments.of("loranet 123", List.of(getComplexComplexDeviceNameFilter(StringOperation.STARTS_WITH, "net", ComplexOperation.OR, StringOperation.STARTS_WITH, "the")), false), + Arguments.of("loranet123", List.of(getComplexComplexDeviceNameFilter(StringOperation.STARTS_WITH, "lo", ComplexOperation.OR, StringOperation.STARTS_WITH, "the", + ComplexOperation.AND, StringOperation.ENDS_WITH, "123")), true), + Arguments.of("loranet 123", List.of(getComplexComplexDeviceNameFilter(StringOperation.STARTS_WITH, "net", ComplexOperation.OR, StringOperation.STARTS_WITH, "the", + ComplexOperation.OR, StringOperation.ENDS_WITH, "123")), true), + Arguments.of("loranet 123", List.of(getComplexComplexDeviceNameFilter(StringOperation.STARTS_WITH, "net", ComplexOperation.OR, StringOperation.STARTS_WITH, "the", + ComplexOperation.AND, StringOperation.ENDS_WITH, "123")), false), + Arguments.of("loranet 123", List.of(getComplexComplexDeviceNameFilter(StringOperation.STARTS_WITH, "lo", ComplexOperation.OR, StringOperation.STARTS_WITH, "the", + ComplexOperation.AND, StringOperation.ENDS_WITH, "124")), false) + ); + } + + @ParameterizedTest + @MethodSource("deviceNameComplexFilters") + public void testFilterByDeviceNameComplexFilters(String deviceName, List keyFilters, boolean result) { + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name(deviceName).build()); + + assertThat(RepositoryUtils.checkKeyFilters(deviceData, keyFilters)).isEqualTo(result); + } + + private static Stream deviceTemperatureFilters() { + return Stream.of(Arguments.of(22.8, getTemperatureFilter(NumericOperation.EQUAL, 22.8), true), + Arguments.of(22.8, getTemperatureFilter(NumericOperation.EQUAL, 22.9), false), + Arguments.of(22.8, getTemperatureFilter(NumericOperation.NOT_EQUAL, 22.8), false), + Arguments.of(22.8, getTemperatureFilter(NumericOperation.NOT_EQUAL, 22.9), true), + Arguments.of(22.8, getTemperatureFilter(NumericOperation.GREATER, 22.0), true), + Arguments.of(22.8, getTemperatureFilter(NumericOperation.GREATER, 23.0), false), + Arguments.of(22.8, getTemperatureFilter(NumericOperation.GREATER_OR_EQUAL, 22.8), true), + Arguments.of(22.8, getTemperatureFilter(NumericOperation.GREATER_OR_EQUAL, 23.0), false), + Arguments.of(22.8, getTemperatureFilter(NumericOperation.LESS, 23.0), true), + Arguments.of(22.8, getTemperatureFilter(NumericOperation.LESS, 22.0), false), + Arguments.of(22.8, getTemperatureFilter(NumericOperation.LESS_OR_EQUAL, 22.0), false), + Arguments.of(22.8, getTemperatureFilter(NumericOperation.LESS_OR_EQUAL, 22.8), true) + ); + } + + @ParameterizedTest + @MethodSource("deviceTemperatureFilters") + public void testFilterByDeviceTemperature(double tempValue, EdqsFilter keyFilter, boolean result) { + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name(StringUtils.randomAlphabetic(10)).build()); + deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), tempValue)); + + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(keyFilter))).isEqualTo(result); + } + + private static Stream deviceTemperatureComplexFilters() { + return Stream.of(Arguments.of(22.8, getComplexTemperatureFilter(NumericOperation.GREATER_OR_EQUAL, 22.8, ComplexOperation.AND, NumericOperation.LESS_OR_EQUAL, 30), true), + Arguments.of(22.8, getComplexTemperatureFilter(NumericOperation.GREATER, 23.5, ComplexOperation.AND, NumericOperation.LESS_OR_EQUAL, 30), false), + Arguments.of(22.8, getComplexComplexTemperatureFilter(NumericOperation.GREATER, 22.0, ComplexOperation.AND, NumericOperation.LESS_OR_EQUAL, 30, ComplexOperation.OR, NumericOperation.GREATER, 35), true), + Arguments.of(22.8, getComplexComplexTemperatureFilter(NumericOperation.GREATER, 22.0, ComplexOperation.AND, NumericOperation.LESS_OR_EQUAL, 30, ComplexOperation.AND, NumericOperation.EQUAL, 22.8), true) + ); + } + + @ParameterizedTest + @MethodSource("deviceTemperatureComplexFilters") + public void testComplexFilterByDeviceTemperature(double tempValue, EdqsFilter keyFilter, boolean result) { + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name(StringUtils.randomAlphabetic(10)).build()); + deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), tempValue)); + + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(keyFilter))).isEqualTo(result); + } + + private static Stream deviceHumidityFilters() { + return Stream.of(Arguments.of(60, getHumidityFilter(NumericOperation.EQUAL, 60), true), + Arguments.of(60, getHumidityFilter(NumericOperation.EQUAL, 61), false), + Arguments.of(60, getHumidityFilter(NumericOperation.NOT_EQUAL, 60), false), + Arguments.of(60, getHumidityFilter(NumericOperation.NOT_EQUAL, 61), true), + Arguments.of(60, getHumidityFilter(NumericOperation.GREATER, 59), true), + Arguments.of(60, getHumidityFilter(NumericOperation.GREATER, 60), false), + Arguments.of(60, getHumidityFilter(NumericOperation.GREATER_OR_EQUAL, 60), true), + Arguments.of(60, getHumidityFilter(NumericOperation.GREATER_OR_EQUAL, 61), false), + Arguments.of(60, getHumidityFilter(NumericOperation.LESS, 61), true), + Arguments.of(60, getHumidityFilter(NumericOperation.LESS, 60), false), + Arguments.of(60, getHumidityFilter(NumericOperation.LESS_OR_EQUAL, 59), false), + Arguments.of(60, getHumidityFilter(NumericOperation.LESS_OR_EQUAL, 60), true) + ); + } + + @ParameterizedTest + @MethodSource("deviceHumidityFilters") + public void testFilterByDeviceHumidity(long humidityValue, EdqsFilter keyFilter, boolean result) { + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name(StringUtils.randomAlphabetic(10)).build()); + deviceData.putTs(6, new LongDataPoint(System.currentTimeMillis(), humidityValue)); + + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(keyFilter))).isEqualTo(result); + } + + private static Stream deviceTemperatureAndHumidityFilters() { + return Stream.of(Arguments.of(22.8, 60, List.of(getTemperatureFilter(NumericOperation.EQUAL, 22.8), getHumidityFilter(NumericOperation.EQUAL, 60)), true), + Arguments.of(22.8, 60, List.of(getTemperatureFilter(NumericOperation.EQUAL, 22.8), getHumidityFilter(NumericOperation.GREATER_OR_EQUAL, 61)), false), + Arguments.of(22.8, 60, List.of(getTemperatureFilter(NumericOperation.GREATER, 23), getHumidityFilter(NumericOperation.GREATER_OR_EQUAL, 60)), false), + Arguments.of(22.8, 60, List.of(getTemperatureFilter(NumericOperation.GREATER_OR_EQUAL, 22.9), getHumidityFilter(NumericOperation.GREATER_OR_EQUAL, 61)), false) + ); + } + + @ParameterizedTest + @MethodSource("deviceTemperatureAndHumidityFilters") + public void testFilterByDeviceTemperatureAndHumidity(double tempValue, long humidityValue, List keyFilters, boolean result) { + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name(StringUtils.randomAlphabetic(10)).build()); + deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), tempValue)); + deviceData.putTs(6, new LongDataPoint(System.currentTimeMillis(), humidityValue)); + + assertThat(RepositoryUtils.checkKeyFilters(deviceData, keyFilters)).isEqualTo(result); + } + + private static Stream deviceVersionAttributeFilters() { + return Stream.of(Arguments.of(true, getActiveAttributeFilter(BooleanOperation.EQUAL, true), true), + Arguments.of(true, getActiveAttributeFilter(BooleanOperation.EQUAL, false), false), + Arguments.of(true, getActiveAttributeFilter(BooleanOperation.NOT_EQUAL, true), false), + Arguments.of(true, getActiveAttributeFilter(BooleanOperation.NOT_EQUAL, false), true) + ); + } + + @ParameterizedTest + @MethodSource("deviceVersionAttributeFilters") + public void testFilterByDeviceVersionAttribute(Boolean active, EdqsFilter keyFilter, boolean result) { + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name(StringUtils.randomAlphabetic(10)).build()); + deviceData.putAttr(2, AttributeScope.SERVER_SCOPE, new BoolDataPoint(System.currentTimeMillis(), active)); + + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(keyFilter))).isEqualTo(result); + } + + private static Stream deviceActiveAndVersionFilters() { + return Stream.of(Arguments.of(true, "3.2.1", List.of(getActiveAttributeFilter(BooleanOperation.EQUAL, true), getVersionAttributeFilter(StringOperation.EQUAL, "3.2.1")), true), + Arguments.of(true, "3.2.1", List.of(getActiveAttributeFilter(BooleanOperation.EQUAL, true), getVersionAttributeFilter(StringOperation.EQUAL, "3.2.2")), false), + Arguments.of(true, "3.2.1", List.of(getActiveAttributeFilter(BooleanOperation.EQUAL, false), getVersionAttributeFilter(StringOperation.EQUAL, "3.2.1")), false), + Arguments.of(true, "3.2.1", List.of(getActiveAttributeFilter(BooleanOperation.EQUAL, false), getVersionAttributeFilter(StringOperation.EQUAL, "3.2.2")), false) + ); + } + + @ParameterizedTest + @MethodSource("deviceActiveAndVersionFilters") + public void testFilterByActiveAndVersionAttributes(Boolean active, String version, List keyFilters, boolean result) { + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name(StringUtils.randomAlphabetic(10)).build()); + deviceData.putAttr(1, AttributeScope.CLIENT_SCOPE, new StringDataPoint(System.currentTimeMillis(), version)); + deviceData.putAttr(2, AttributeScope.SERVER_SCOPE, new BoolDataPoint(System.currentTimeMillis(), active)); + + assertThat(RepositoryUtils.checkKeyFilters(deviceData, keyFilters)).isEqualTo(result); + } + + private static EdqsFilter getVersionAttributeFilter(StringOperation operation, String predicateValue) { + StringFilterPredicate filterPredicate = new StringFilterPredicate(); + filterPredicate.setOperation(operation); + filterPredicate.setValue(FilterPredicateValue.fromString(predicateValue)); + + DataKey key = new DataKey(EntityKeyType.CLIENT_ATTRIBUTE, "version", 1); + return new EdqsFilter(key, EntityKeyValueType.STRING, filterPredicate); + } + + + private static EdqsFilter getActiveAttributeFilter(BooleanOperation operation, boolean predicateValue) { + BooleanFilterPredicate filterPredicate = new BooleanFilterPredicate(); + filterPredicate.setOperation(operation); + filterPredicate.setValue(FilterPredicateValue.fromBoolean(predicateValue)); + + DataKey key = new DataKey(EntityKeyType.SERVER_ATTRIBUTE, "active", 2); + return new EdqsFilter(key, EntityKeyValueType.BOOLEAN, filterPredicate); + } + + private static EdqsFilter getTemperatureFilter(NumericOperation operation, double predicateValue) { + return getTimeseriesFilter("temperature", 5, operation, predicateValue); + } + + private static EdqsFilter getHumidityFilter(NumericOperation operation, double predicateValue) { + return getTimeseriesFilter("humidity", 6, operation, predicateValue); + } + + private static EdqsFilter getTimeseriesFilter(String key, Integer keysId, NumericOperation operation, double predicateValue) { + NumericFilterPredicate filterPredicate = new NumericFilterPredicate(); + filterPredicate.setOperation(operation); + filterPredicate.setValue(FilterPredicateValue.fromDouble(predicateValue)); + + DataKey newKey = new DataKey(EntityKeyType.TIME_SERIES, key, keysId); + return new EdqsFilter(newKey, EntityKeyValueType.NUMERIC, filterPredicate); + } + + private static EdqsFilter getNameFilter(StringOperation operation, String predicateValue) { + return getStringEntityFieldFilter("name", operation, predicateValue); + } + + private static EdqsFilter getTypeFilter(StringOperation operation, String predicateValue) { + return getStringEntityFieldFilter("type", operation, predicateValue); + } + + private static EdqsFilter getStringEntityFieldFilter(String fieldName, StringOperation operation, String predicateValue) { + StringFilterPredicate filterPredicate = new StringFilterPredicate(); + filterPredicate.setOperation(operation); + filterPredicate.setValue(FilterPredicateValue.fromString(predicateValue)); + + DataKey key = new DataKey(EntityKeyType.ENTITY_FIELD, fieldName, 3); + return new EdqsFilter(key, EntityKeyValueType.STRING, filterPredicate); + } + + private static EdqsFilter getCreatedTimeFilter(NumericOperation operation, double predicateValue) { + return getDatetimeEntityFieldFilter("createdTime", operation, predicateValue); + } + + private static EdqsFilter getDatetimeEntityFieldFilter(String fieldName, NumericOperation operation, double predicateValue) { + NumericFilterPredicate filterPredicate = new NumericFilterPredicate(); + filterPredicate.setOperation(operation); + filterPredicate.setValue(FilterPredicateValue.fromDouble(predicateValue)); + + DataKey key = new DataKey(EntityKeyType.ENTITY_FIELD, fieldName, 3); + return new EdqsFilter(key, EntityKeyValueType.DATE_TIME, filterPredicate); + } + + private static EdqsFilter getComplexTemperatureFilter(NumericOperation operation, double predicateValue, ComplexOperation complexOperation, NumericOperation operation2, double predicateValue2) { + ComplexFilterPredicate complexFilterPredicate = getComplexNumericFilterPredicate(operation, predicateValue, complexOperation, operation2, predicateValue2); + + DataKey key = new DataKey(EntityKeyType.TIME_SERIES, "temperature", 5); + return new EdqsFilter(key, EntityKeyValueType.NUMERIC, complexFilterPredicate); + } + + private static EdqsFilter getComplexComplexTemperatureFilter(NumericOperation operation, double predicateValue, ComplexOperation complexOperation, NumericOperation operation2, double predicateValue2, + ComplexOperation complexOperation2, NumericOperation operation3, double predicateValue3) { + ComplexFilterPredicate complexFilterPredicate = getComplexNumericFilterPredicate(operation, predicateValue, complexOperation, operation2, predicateValue2); + + NumericFilterPredicate filterPredicate = new NumericFilterPredicate(); + filterPredicate.setOperation(operation); + filterPredicate.setValue(FilterPredicateValue.fromDouble(predicateValue)); + + ComplexFilterPredicate mainComplexFilterPredicate = new ComplexFilterPredicate(); + mainComplexFilterPredicate.setOperation(complexOperation2); + mainComplexFilterPredicate.setPredicates(List.of(complexFilterPredicate, filterPredicate)); + + DataKey key = new DataKey(EntityKeyType.TIME_SERIES, "temperature", 5); + return new EdqsFilter(key, EntityKeyValueType.NUMERIC, mainComplexFilterPredicate); + } + + private static ComplexFilterPredicate getComplexNumericFilterPredicate(NumericOperation operation, double predicateValue, ComplexOperation complexOperation, NumericOperation operation2, double predicateValue2) { + NumericFilterPredicate filterPredicate = new NumericFilterPredicate(); + filterPredicate.setOperation(operation); + filterPredicate.setValue(FilterPredicateValue.fromDouble(predicateValue)); + + NumericFilterPredicate filterPredicate2 = new NumericFilterPredicate(); + filterPredicate2.setOperation(operation2); + filterPredicate2.setValue(FilterPredicateValue.fromDouble(predicateValue2)); + + ComplexFilterPredicate complexFilterPredicate = new ComplexFilterPredicate(); + complexFilterPredicate.setOperation(complexOperation); + complexFilterPredicate.setPredicates(List.of(filterPredicate, filterPredicate2)); + return complexFilterPredicate; + } + + private static EdqsFilter getComplexComplexDeviceNameFilter(StringOperation operation, String predicateValue, ComplexOperation complexOperation, StringOperation operation2, String predicateValue2) { + ComplexFilterPredicate complexFilterPredicate = getComplexStringFilterPredicate(operation, predicateValue, complexOperation, operation2, predicateValue2); + DataKey key = new DataKey(EntityKeyType.ENTITY_FIELD, "name", 3); + return new EdqsFilter(key, EntityKeyValueType.STRING, complexFilterPredicate); + } + + private static EdqsFilter getComplexComplexDeviceNameFilter(StringOperation operation, String predicateValue, ComplexOperation complexOperation, StringOperation operation2, String predicateValue2, + ComplexOperation complexOperation2, StringOperation operation3, String predicateValue3) { + ComplexFilterPredicate complexFilterPredicate = getComplexStringFilterPredicate(operation, predicateValue, complexOperation, operation2, predicateValue2); + + StringFilterPredicate filterPredicate = new StringFilterPredicate(); + filterPredicate.setOperation(operation3); + filterPredicate.setValue(FilterPredicateValue.fromString(predicateValue3)); + + ComplexFilterPredicate mainComplexFilterPredicate = new ComplexFilterPredicate(); + mainComplexFilterPredicate.setOperation(complexOperation2); + mainComplexFilterPredicate.setPredicates(List.of(complexFilterPredicate, filterPredicate)); + + DataKey key = new DataKey(EntityKeyType.ENTITY_FIELD, "name", 3); + return new EdqsFilter(key, EntityKeyValueType.STRING, mainComplexFilterPredicate); + } + + private static ComplexFilterPredicate getComplexStringFilterPredicate(StringOperation operation, String predicateValue, ComplexOperation complexOperation, StringOperation operation2, String predicateValue2) { + StringFilterPredicate filterPredicate = new StringFilterPredicate(); + filterPredicate.setOperation(operation); + filterPredicate.setValue(FilterPredicateValue.fromString(predicateValue)); + + StringFilterPredicate filterPredicate2 = new StringFilterPredicate(); + filterPredicate2.setOperation(operation2); + filterPredicate2.setValue(FilterPredicateValue.fromString(predicateValue2)); + + ComplexFilterPredicate complexFilterPredicate = new ComplexFilterPredicate(); + complexFilterPredicate.setOperation(complexOperation); + complexFilterPredicate.setPredicates(List.of(filterPredicate, filterPredicate2)); + return complexFilterPredicate; + } + +} diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/SingleEntityFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/SingleEntityFilterTest.java new file mode 100644 index 0000000000..133816f576 --- /dev/null +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/SingleEntityFilterTest.java @@ -0,0 +1,133 @@ +/** + * 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. + */ +package org.thingsboard.server.edqs.repo; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.LatestTsKv; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityKeyValueType; +import org.thingsboard.server.common.data.query.FilterPredicateValue; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.SingleEntityFilter; +import org.thingsboard.server.common.data.query.StringFilterPredicate; + +import java.util.Arrays; +import java.util.UUID; + +public class SingleEntityFilterTest extends AbstractEDQTest { + + @Before + public void setUp() { + } + + @After + public void tearDown() { + } + + @Test + public void testFindTenantDevice() { + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + Device device = new Device(); + device.setId(deviceId); + device.setTenantId(tenantId); + device.setName("LoRa-1"); + device.setCreatedTime(42L); + device.setDeviceProfileId(new DeviceProfileId(defaultDeviceProfileId)); + addOrUpdate(EntityType.DEVICE, device); + addOrUpdate(new LatestTsKv(deviceId, new BasicTsKvEntry(43, new StringDataEntry("state", "TEST")), 0L)); + + var result = repository.findEntityDataByQuery(tenantId, null, getEntityDataQuery(device.getId()), false); + + Assert.assertEquals(1, result.getTotalElements()); + var first = result.getData().get(0); + Assert.assertEquals(deviceId, first.getEntityId()); + Assert.assertEquals("LoRa-1", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()); + Assert.assertEquals("42", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()); + + result = repository.findEntityDataByQuery(tenantId, null, getEntityDataQuery(new DeviceId(UUID.randomUUID())), false); + Assert.assertEquals(0, result.getTotalElements()); + + device.setCustomerId(customerId); + addOrUpdate(EntityType.DEVICE, device); + + result = repository.findEntityDataByQuery(tenantId, null, getEntityDataQuery(device.getId()), false); + Assert.assertEquals(1, result.getTotalElements()); + first = result.getData().get(0); + Assert.assertEquals(deviceId, first.getEntityId()); + Assert.assertEquals("LoRa-1", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()); + Assert.assertEquals("42", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()); + } + + @Test + public void testFindCustomerDevice() { + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + Device device = new Device(); + device.setId(deviceId); + device.setTenantId(tenantId); + device.setName("LoRa-1"); + device.setCreatedTime(42L); + device.setDeviceProfileId(new DeviceProfileId(defaultDeviceProfileId)); + addOrUpdate(EntityType.DEVICE, device); + addOrUpdate(new LatestTsKv(deviceId, new BasicTsKvEntry(43, new StringDataEntry("state", "TEST")), 0L)); + + var result = repository.findEntityDataByQuery(tenantId, customerId, getEntityDataQuery(device.getId()), false); + Assert.assertEquals(0, result.getTotalElements()); + + device.setCustomerId(customerId); + addOrUpdate(EntityType.DEVICE, device); + + result = repository.findEntityDataByQuery(tenantId, customerId, getEntityDataQuery(device.getId()), false); + + Assert.assertEquals(1, result.getTotalElements()); + var first = result.getData().get(0); + Assert.assertEquals(deviceId, first.getEntityId()); + Assert.assertEquals("LoRa-1", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()); + Assert.assertEquals("42", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()); + } + + private static EntityDataQuery getEntityDataQuery(DeviceId deviceId) { + SingleEntityFilter filter = new SingleEntityFilter(); + filter.setSingleEntity(deviceId); + var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.TIME_SERIES, "state"), EntityDataSortOrder.Direction.DESC), false); + + var entityFields = Arrays.asList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime")); + var latestValues = Arrays.asList(new EntityKey(EntityKeyType.TIME_SERIES, "state")); + KeyFilter nameFilter = new KeyFilter(); + nameFilter.setKey(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + var predicate = new StringFilterPredicate(); + predicate.setIgnoreCase(false); + predicate.setOperation(StringFilterPredicate.StringOperation.CONTAINS); + predicate.setValue(new FilterPredicateValue<>("LoRa-")); + nameFilter.setPredicate(predicate); + nameFilter.setValueType(EntityKeyValueType.STRING); + + return new EntityDataQuery(filter, pageLink, entityFields, latestValues, Arrays.asList(nameFilter)); + } + +} diff --git a/edqs/src/test/resources/edqs-test.properties b/edqs/src/test/resources/edqs-test.properties new file mode 100644 index 0000000000..8a041c7407 --- /dev/null +++ b/edqs/src/test/resources/edqs-test.properties @@ -0,0 +1,2 @@ +zk.enabled=false +service.type=edqs diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/TransportHealthChecker.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/TransportHealthChecker.java index 1b8a704fad..f5e6cb5469 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/TransportHealthChecker.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/TransportHealthChecker.java @@ -145,7 +145,7 @@ public abstract class TransportHealthChecker { TbResource newResource = ResourceUtils.getResource("lwm2m/resource.json", TbResource.class); diff --git a/monitoring/src/main/resources/lwm2m/device_profile.json b/monitoring/src/main/resources/lwm2m/device_profile.json index c75fe9ce7a..d23e02825d 100644 --- a/monitoring/src/main/resources/lwm2m/device_profile.json +++ b/monitoring/src/main/resources/lwm2m/device_profile.json @@ -12,14 +12,14 @@ "transportConfiguration": { "observeAttr": { "observe": [ - "/3_1.0/0/0" + "/3_1.1/0/0" ], "attribute": [], "telemetry": [ - "/3_1.0/0/0" + "/3_1.1/0/0" ], "keyName": { - "/3_1.0/0/0": "testData" + "/3_1.1/0/0": "testData" }, "attributeLwm2m": {} }, diff --git a/monitoring/src/main/resources/lwm2m/models/test-model.xml b/monitoring/src/main/resources/lwm2m/models/test-model.xml index 2d25f4698e..02b084127f 100644 --- a/monitoring/src/main/resources/lwm2m/models/test-model.xml +++ b/monitoring/src/main/resources/lwm2m/models/test-model.xml @@ -1,45 +1,331 @@ + - + + LwM2M Monitoring - - + 3 - urn:oma:lwm2m:oma:3 + urn:oma:lwm2m:oma:3:1.1 1.1 - 1.0 + 1.1 Single Mandatory - Test data + Manufacturer + R + Single + Optional + String + + + + + + Model Number R Single Optional String - + - + + Serial Number + R + Single + Optional + String + + + + + + Firmware Version + R + Single + Optional + String + + + + + + Reboot + E + Single + Mandatory + + + + + + + Factory Reset + E + Single + Optional + + + + + + + Available Power Sources + R + Multiple + Optional + Integer + 0..7 + + + + + Power Source Voltage + R + Multiple + Optional + Integer + + + + + + Power Source Current + R + Multiple + Optional + Integer + + + + + + Battery Level + R + Single + Optional + Integer + 0..100 + /100 + + + + Memory Free + R + Single + Optional + Integer + + + + + + Error Code + R + Multiple + Mandatory + Integer + 0..32 + + + + + Reset Error Code + E + Single + Optional + + + + + + + Current Time + RW + Single + Optional + Time + + + + + + UTC Offset + RW + Single + Optional + String + + + + + + Timezone + RW + Single + Optional + String + + + + + + Supported Binding and Modes + R + Single + Mandatory + String + + + + + Device Type + R + Single + Optional + String + + + + + Hardware Version + R + Single + Optional + String + + + + + Software Version + R + Single + Optional + String + + + + + Battery Status + R + Single + Optional + Integer + 0..6 + + + + Memory Total + R + Single + Optional + Integer + + + + + ExtDevInfo + R + Multiple + Optional + Objlnk + + + + diff --git a/monitoring/src/main/resources/lwm2m/resource.json b/monitoring/src/main/resources/lwm2m/resource.json index dcc0182bdd..bbef8da7ec 100644 --- a/monitoring/src/main/resources/lwm2m/resource.json +++ b/monitoring/src/main/resources/lwm2m/resource.json @@ -2,5 +2,5 @@ "title": "", "resourceType": "LWM2M_MODEL", "fileName": "test-model.xml", - "data": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCEtLQoKICAgIENvcHlyaWdodCDCqSAyMDE2LTIwMjQgVGhlIFRoaW5nc2JvYXJkIEF1dGhvcnMKCiAgICBMaWNlbnNlZCB1bmRlciB0aGUgQXBhY2hlIExpY2Vuc2UsIFZlcnNpb24gMi4wICh0aGUgIkxpY2Vuc2UiKTsKICAgIHlvdSBtYXkgbm90IHVzZSB0aGlzIGZpbGUgZXhjZXB0IGluIGNvbXBsaWFuY2Ugd2l0aCB0aGUgTGljZW5zZS4KICAgIFlvdSBtYXkgb2J0YWluIGEgY29weSBvZiB0aGUgTGljZW5zZSBhdAoKICAgICAgICBodHRwOi8vd3d3LmFwYWNoZS5vcmcvbGljZW5zZXMvTElDRU5TRS0yLjAKCiAgICBVbmxlc3MgcmVxdWlyZWQgYnkgYXBwbGljYWJsZSBsYXcgb3IgYWdyZWVkIHRvIGluIHdyaXRpbmcsIHNvZnR3YXJlCiAgICBkaXN0cmlidXRlZCB1bmRlciB0aGUgTGljZW5zZSBpcyBkaXN0cmlidXRlZCBvbiBhbiAiQVMgSVMiIEJBU0lTLAogICAgV0lUSE9VVCBXQVJSQU5USUVTIE9SIENPTkRJVElPTlMgT0YgQU5ZIEtJTkQsIGVpdGhlciBleHByZXNzIG9yIGltcGxpZWQuCiAgICBTZWUgdGhlIExpY2Vuc2UgZm9yIHRoZSBzcGVjaWZpYyBsYW5ndWFnZSBnb3Zlcm5pbmcgcGVybWlzc2lvbnMgYW5kCiAgICBsaW1pdGF0aW9ucyB1bmRlciB0aGUgTGljZW5zZS4KCi0tPgo8TFdNMk0geG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIKICAgICAgIHhzaTpub05hbWVzcGFjZVNjaGVtYUxvY2F0aW9uPSJodHRwOi8vd3d3Lm9wZW5tb2JpbGVhbGxpYW5jZS5vcmcvdGVjaC9wcm9maWxlcy9MV00yTS12MV8xLnhzZCI+CiAgICA8T2JqZWN0IE9iamVjdFR5cGU9Ik1PRGVmaW5pdGlvbiI+CiAgICAgICAgPE5hbWU+THdNMk0gTW9uaXRvcmluZzwvTmFtZT4KICAgICAgICA8RGVzY3JpcHRpb24xPgogICAgICAgICAgICA8IVtDREFUQVtdXT48L0Rlc2NyaXB0aW9uMT4KICAgICAgICA8T2JqZWN0SUQ+MzwvT2JqZWN0SUQ+CiAgICAgICAgPE9iamVjdFVSTj51cm46b21hOmx3bTJtOm9tYTozPC9PYmplY3RVUk4+CiAgICAgICAgPExXTTJNVmVyc2lvbj4xLjE8L0xXTTJNVmVyc2lvbj4KICAgICAgICA8T2JqZWN0VmVyc2lvbj4xLjA8L09iamVjdFZlcnNpb24+CiAgICAgICAgPE11bHRpcGxlSW5zdGFuY2VzPlNpbmdsZTwvTXVsdGlwbGVJbnN0YW5jZXM+CiAgICAgICAgPE1hbmRhdG9yeT5NYW5kYXRvcnk8L01hbmRhdG9yeT4KICAgICAgICA8UmVzb3VyY2VzPgogICAgICAgICAgICA8SXRlbSBJRD0iMCI+CiAgICAgICAgICAgICAgICA8TmFtZT5UZXN0IGRhdGE8L05hbWU+CiAgICAgICAgICAgICAgICA8T3BlcmF0aW9ucz5SPC9PcGVyYXRpb25zPgogICAgICAgICAgICAgICAgPE11bHRpcGxlSW5zdGFuY2VzPlNpbmdsZTwvTXVsdGlwbGVJbnN0YW5jZXM+CiAgICAgICAgICAgICAgICA8TWFuZGF0b3J5Pk9wdGlvbmFsPC9NYW5kYXRvcnk+CiAgICAgICAgICAgICAgICA8VHlwZT5TdHJpbmc8L1R5cGU+CiAgICAgICAgICAgICAgICA8UmFuZ2VFbnVtZXJhdGlvbj48L1JhbmdlRW51bWVyYXRpb24+CiAgICAgICAgICAgICAgICA8VW5pdHM+PC9Vbml0cz4KICAgICAgICAgICAgICAgIDxEZXNjcmlwdGlvbj48IVtDREFUQVtUZXN0IGRhdGFdXT48L0Rlc2NyaXB0aW9uPgogICAgICAgICAgICA8L0l0ZW0+CiAgICAgICAgPC9SZXNvdXJjZXM+CiAgICAgICAgPERlc2NyaXB0aW9uMj48L0Rlc2NyaXB0aW9uMj4KICAgIDwvT2JqZWN0Pgo8L0xXTTJNPgo=" + "data": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KCjwhLS0KRklMRSBJTkZPUk1BVElPTgoKT01BIFBlcm1hbmVudCBEb2N1bWVudAogICBGaWxlOiBPTUEtU1VQLVhNTF8zLVYxXzItMjAyMDExMTAtQS54bWwKICAgUGF0aDogaHR0cDovL3d3dy5vcGVubW9iaWxlYWxsaWFuY2Uub3JnL3JlbGVhc2UvT2JqTHdNMk1fRGV2aWNlLwoKT01OQSBMd00yTSBSZWdpc3RyeQogICBQYXRoOiBodHRwczovL2dpdGh1Yi5jb20vT3Blbk1vYmlsZUFsbGlhbmNlL2x3bTJtLXJlZ2lzdHJ5CiAgIE5hbWU6IDMueG1sCgpOT1JNQVRJVkUgSU5GT1JNQVRJT04KCiAgSW5mb3JtYXRpb24gYWJvdXQgdGhpcyBmaWxlIGNhbiBiZSBmb3VuZCBpbiB0aGUgbGF0ZXN0IHJldmlzaW9uIG9mCgogICAgT01BLVRTLUxpZ2h0d2VpZ2h0TTJNX0NvcmUtVjFfMgoKICBUaGlzIGlzIGF2YWlsYWJsZSBhdCBodHRwOi8vd3d3Lm9wZW5tb2JpbGVhbGxpYW5jZS5vcmcvcmVsZWFzZS9MaWdodHdlaWdodE0yTS8KCiAgU2VuZCBjb21tZW50cyB0byBodHRwczovL2dpdGh1Yi5jb20vT3Blbk1vYmlsZUFsbGlhbmNlL09NQV9Md00yTV9mb3JfRGV2ZWxvcGVycy9pc3N1ZXMKCkxFR0FMIERJU0NMQUlNRVIKCiAgQ29weXJpZ2h0IDIwMjAgT3BlbiBNb2JpbGUgQWxsaWFuY2UuCgogIFJlZGlzdHJpYnV0aW9uIGFuZCB1c2UgaW4gc291cmNlIGFuZCBiaW5hcnkgZm9ybXMsIHdpdGggb3Igd2l0aG91dAogIG1vZGlmaWNhdGlvbiwgYXJlIHBlcm1pdHRlZCBwcm92aWRlZCB0aGF0IHRoZSBmb2xsb3dpbmcgY29uZGl0aW9ucwogIGFyZSBtZXQ6CgogIDEuIFJlZGlzdHJpYnV0aW9ucyBvZiBzb3VyY2UgY29kZSBtdXN0IHJldGFpbiB0aGUgYWJvdmUgY29weXJpZ2h0CiAgbm90aWNlLCB0aGlzIGxpc3Qgb2YgY29uZGl0aW9ucyBhbmQgdGhlIGZvbGxvd2luZyBkaXNjbGFpbWVyLgogIDIuIFJlZGlzdHJpYnV0aW9ucyBpbiBiaW5hcnkgZm9ybSBtdXN0IHJlcHJvZHVjZSB0aGUgYWJvdmUgY29weXJpZ2h0CiAgbm90aWNlLCB0aGlzIGxpc3Qgb2YgY29uZGl0aW9ucyBhbmQgdGhlIGZvbGxvd2luZyBkaXNjbGFpbWVyIGluIHRoZQogIGRvY3VtZW50YXRpb24gYW5kL29yIG90aGVyIG1hdGVyaWFscyBwcm92aWRlZCB3aXRoIHRoZSBkaXN0cmlidXRpb24uCiAgMy4gTmVpdGhlciB0aGUgbmFtZSBvZiB0aGUgY29weXJpZ2h0IGhvbGRlciBub3IgdGhlIG5hbWVzIG9mIGl0cwogIGNvbnRyaWJ1dG9ycyBtYXkgYmUgdXNlZCB0byBlbmRvcnNlIG9yIHByb21vdGUgcHJvZHVjdHMgZGVyaXZlZAogIGZyb20gdGhpcyBzb2Z0d2FyZSB3aXRob3V0IHNwZWNpZmljIHByaW9yIHdyaXR0ZW4gcGVybWlzc2lvbi4KCiAgVEhJUyBTT0ZUV0FSRSBJUyBQUk9WSURFRCBCWSBUSEUgQ09QWVJJR0hUIEhPTERFUlMgQU5EIENPTlRSSUJVVE9SUwogICJBUyBJUyIgQU5EIEFOWSBFWFBSRVNTIE9SIElNUExJRUQgV0FSUkFOVElFUywgSU5DTFVESU5HLCBCVVQgTk9UCiAgTElNSVRFRCBUTywgVEhFIElNUExJRUQgV0FSUkFOVElFUyBPRiBNRVJDSEFOVEFCSUxJVFkgQU5EIEZJVE5FU1MKICBGT1IgQSBQQVJUSUNVTEFSIFBVUlBPU0UgQVJFIERJU0NMQUlNRUQuIElOIE5PIEVWRU5UIFNIQUxMIFRIRQogIENPUFlSSUdIVCBIT0xERVIgT1IgQ09OVFJJQlVUT1JTIEJFIExJQUJMRSBGT1IgQU5ZIERJUkVDVCwgSU5ESVJFQ1QsCiAgSU5DSURFTlRBTCwgU1BFQ0lBTCwgRVhFTVBMQVJZLCBPUiBDT05TRVFVRU5USUFMIERBTUFHRVMgKElOQ0xVRElORywKICBCVVQgTk9UIExJTUlURUQgVE8sIFBST0NVUkVNRU5UIE9GIFNVQlNUSVRVVEUgR09PRFMgT1IgU0VSVklDRVM7CiAgTE9TUyBPRiBVU0UsIERBVEEsIE9SIFBST0ZJVFM7IE9SIEJVU0lORVNTIElOVEVSUlVQVElPTikgSE9XRVZFUgogIENBVVNFRCBBTkQgT04gQU5ZIFRIRU9SWSBPRiBMSUFCSUxJVFksIFdIRVRIRVIgSU4gQ09OVFJBQ1QsIFNUUklDVAogIExJQUJJTElUWSwgT1IgVE9SVCAoSU5DTFVESU5HIE5FR0xJR0VOQ0UgT1IgT1RIRVJXSVNFKSBBUklTSU5HIElOCiAgQU5ZIFdBWSBPVVQgT0YgVEhFIFVTRSBPRiBUSElTIFNPRlRXQVJFLCBFVkVOIElGIEFEVklTRUQgT0YgVEhFCiAgUE9TU0lCSUxJVFkgT0YgU1VDSCBEQU1BR0UuCgogIFRoZSBhYm92ZSBsaWNlbnNlIGlzIHVzZWQgYXMgYSBsaWNlbnNlIHVuZGVyIGNvcHlyaWdodCBvbmx5LiAgUGxlYXNlCiAgcmVmZXJlbmNlIHRoZSBPTUEgSVBSIFBvbGljeSBmb3IgcGF0ZW50IGxpY2Vuc2luZyB0ZXJtczoKICBodHRwczovL3d3dy5vbWFzcGVjd29ya3Mub3JnL2Fib3V0L2ludGVsbGVjdHVhbC1wcm9wZXJ0eS1yaWdodHMvCgotLT4KCjxMV00yTSB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6bm9OYW1lc3BhY2VTY2hlbWFMb2NhdGlvbj0iaHR0cDovL3d3dy5vcGVubW9iaWxlYWxsaWFuY2Uub3JnL3RlY2gvcHJvZmlsZXMvTFdNMk0tdjFfMS54c2QiPgogICAgPE9iamVjdCBPYmplY3RUeXBlPSJNT0RlZmluaXRpb24iPgogICAgICAgIDxOYW1lPkx3TTJNIE1vbml0b3Jpbmc8L05hbWU+CiAgICAgICAgPERlc2NyaXB0aW9uMT48IVtDREFUQVtUaGlzIEx3TTJNIE9iamVjdCBwcm92aWRlcyBhIHJhbmdlIG9mIGRldmljZSByZWxhdGVkIGluZm9ybWF0aW9uIHdoaWNoIGNhbiBiZSBxdWVyaWVkIGJ5IHRoZSBMd00yTSBTZXJ2ZXIsIGFuZCBhIGRldmljZSByZWJvb3QgYW5kIGZhY3RvcnkgcmVzZXQgZnVuY3Rpb24uXV0+PC9EZXNjcmlwdGlvbjE+CiAgICAgICAgPE9iamVjdElEPjM8L09iamVjdElEPgogICAgICAgIDxPYmplY3RVUk4+dXJuOm9tYTpsd20ybTpvbWE6MzoxLjE8L09iamVjdFVSTj4KICAgICAgICA8TFdNMk1WZXJzaW9uPjEuMTwvTFdNMk1WZXJzaW9uPgogICAgICAgIDxPYmplY3RWZXJzaW9uPjEuMTwvT2JqZWN0VmVyc2lvbj4KICAgICAgICA8TXVsdGlwbGVJbnN0YW5jZXM+U2luZ2xlPC9NdWx0aXBsZUluc3RhbmNlcz4KICAgICAgICA8TWFuZGF0b3J5Pk1hbmRhdG9yeTwvTWFuZGF0b3J5PgogICAgICAgIDxSZXNvdXJjZXM+CiAgICAgICAgICAgIDxJdGVtIElEPSIwIj4KICAgICAgICAgICAgICAgIDxOYW1lPk1hbnVmYWN0dXJlcjwvTmFtZT4KICAgICAgICAgICAgICAgIDxPcGVyYXRpb25zPlI8L09wZXJhdGlvbnM+CiAgICAgICAgICAgICAgICA8TXVsdGlwbGVJbnN0YW5jZXM+U2luZ2xlPC9NdWx0aXBsZUluc3RhbmNlcz4KICAgICAgICAgICAgICAgIDxNYW5kYXRvcnk+T3B0aW9uYWw8L01hbmRhdG9yeT4KICAgICAgICAgICAgICAgIDxUeXBlPlN0cmluZzwvVHlwZT4KICAgICAgICAgICAgICAgIDxSYW5nZUVudW1lcmF0aW9uPjwvUmFuZ2VFbnVtZXJhdGlvbj4KICAgICAgICAgICAgICAgIDxVbml0cz48L1VuaXRzPgogICAgICAgICAgICAgICAgPERlc2NyaXB0aW9uPjwhW0NEQVRBW0h1bWFuIHJlYWRhYmxlIG1hbnVmYWN0dXJlciBuYW1lXV0+PC9EZXNjcmlwdGlvbj4KICAgICAgICAgICAgPC9JdGVtPgogICAgICAgICAgICA8SXRlbSBJRD0iMSI+CiAgICAgICAgICAgICAgICA8TmFtZT5Nb2RlbCBOdW1iZXI8L05hbWU+CiAgICAgICAgICAgICAgICA8T3BlcmF0aW9ucz5SPC9PcGVyYXRpb25zPgogICAgICAgICAgICAgICAgPE11bHRpcGxlSW5zdGFuY2VzPlNpbmdsZTwvTXVsdGlwbGVJbnN0YW5jZXM+CiAgICAgICAgICAgICAgICA8TWFuZGF0b3J5Pk9wdGlvbmFsPC9NYW5kYXRvcnk+CiAgICAgICAgICAgICAgICA8VHlwZT5TdHJpbmc8L1R5cGU+CiAgICAgICAgICAgICAgICA8UmFuZ2VFbnVtZXJhdGlvbj48L1JhbmdlRW51bWVyYXRpb24+CiAgICAgICAgICAgICAgICA8VW5pdHM+PC9Vbml0cz4KICAgICAgICAgICAgICAgIDxEZXNjcmlwdGlvbj48IVtDREFUQVtBIG1vZGVsIGlkZW50aWZpZXIgKG1hbnVmYWN0dXJlciBzcGVjaWZpZWQgc3RyaW5nKV1dPjwvRGVzY3JpcHRpb24+CiAgICAgICAgICAgIDwvSXRlbT4KICAgICAgICAgICAgPEl0ZW0gSUQ9IjIiPgogICAgICAgICAgICAgICAgPE5hbWU+U2VyaWFsIE51bWJlcjwvTmFtZT4KICAgICAgICAgICAgICAgIDxPcGVyYXRpb25zPlI8L09wZXJhdGlvbnM+CiAgICAgICAgICAgICAgICA8TXVsdGlwbGVJbnN0YW5jZXM+U2luZ2xlPC9NdWx0aXBsZUluc3RhbmNlcz4KICAgICAgICAgICAgICAgIDxNYW5kYXRvcnk+T3B0aW9uYWw8L01hbmRhdG9yeT4KICAgICAgICAgICAgICAgIDxUeXBlPlN0cmluZzwvVHlwZT4KICAgICAgICAgICAgICAgIDxSYW5nZUVudW1lcmF0aW9uPjwvUmFuZ2VFbnVtZXJhdGlvbj4KICAgICAgICAgICAgICAgIDxVbml0cz48L1VuaXRzPgogICAgICAgICAgICAgICAgPERlc2NyaXB0aW9uPjwhW0NEQVRBW1NlcmlhbCBOdW1iZXJdXT48L0Rlc2NyaXB0aW9uPgogICAgICAgICAgICA8L0l0ZW0+CiAgICAgICAgICAgIDxJdGVtIElEPSIzIj4KICAgICAgICAgICAgICAgIDxOYW1lPkZpcm13YXJlIFZlcnNpb248L05hbWU+CiAgICAgICAgICAgICAgICA8T3BlcmF0aW9ucz5SPC9PcGVyYXRpb25zPgogICAgICAgICAgICAgICAgPE11bHRpcGxlSW5zdGFuY2VzPlNpbmdsZTwvTXVsdGlwbGVJbnN0YW5jZXM+CiAgICAgICAgICAgICAgICA8TWFuZGF0b3J5Pk9wdGlvbmFsPC9NYW5kYXRvcnk+CiAgICAgICAgICAgICAgICA8VHlwZT5TdHJpbmc8L1R5cGU+CiAgICAgICAgICAgICAgICA8UmFuZ2VFbnVtZXJhdGlvbj48L1JhbmdlRW51bWVyYXRpb24+CiAgICAgICAgICAgICAgICA8VW5pdHM+PC9Vbml0cz4KICAgICAgICAgICAgICAgIDxEZXNjcmlwdGlvbj48IVtDREFUQVtDdXJyZW50IGZpcm13YXJlIHZlcnNpb24gb2YgdGhlIERldmljZS5UaGUgRmlybXdhcmUgTWFuYWdlbWVudCBmdW5jdGlvbiBjb3VsZCByZWx5IG9uIHRoaXMgcmVzb3VyY2UuXV0+PC9EZXNjcmlwdGlvbj4KICAgICAgICAgICAgPC9JdGVtPgogICAgICAgICAgICA8SXRlbSBJRD0iNCI+CiAgICAgICAgICAgICAgICA8TmFtZT5SZWJvb3Q8L05hbWU+CiAgICAgICAgICAgICAgICA8T3BlcmF0aW9ucz5FPC9PcGVyYXRpb25zPgogICAgICAgICAgICAgICAgPE11bHRpcGxlSW5zdGFuY2VzPlNpbmdsZTwvTXVsdGlwbGVJbnN0YW5jZXM+CiAgICAgICAgICAgICAgICA8TWFuZGF0b3J5Pk1hbmRhdG9yeTwvTWFuZGF0b3J5PgogICAgICAgICAgICAgICAgPFR5cGU+PC9UeXBlPgogICAgICAgICAgICAgICAgPFJhbmdlRW51bWVyYXRpb24+PC9SYW5nZUVudW1lcmF0aW9uPgogICAgICAgICAgICAgICAgPFVuaXRzPjwvVW5pdHM+CiAgICAgICAgICAgICAgICA8RGVzY3JpcHRpb24+PCFbQ0RBVEFbUmVib290IHRoZSBMd00yTSBEZXZpY2UgdG8gcmVzdG9yZSB0aGUgRGV2aWNlIGZyb20gdW5leHBlY3RlZCBmaXJtd2FyZSBmYWlsdXJlLl1dPjwvRGVzY3JpcHRpb24+CiAgICAgICAgICAgIDwvSXRlbT4KICAgICAgICAgICAgPEl0ZW0gSUQ9IjUiPgogICAgICAgICAgICAgICAgPE5hbWU+RmFjdG9yeSBSZXNldDwvTmFtZT4KICAgICAgICAgICAgICAgIDxPcGVyYXRpb25zPkU8L09wZXJhdGlvbnM+CiAgICAgICAgICAgICAgICA8TXVsdGlwbGVJbnN0YW5jZXM+U2luZ2xlPC9NdWx0aXBsZUluc3RhbmNlcz4KICAgICAgICAgICAgICAgIDxNYW5kYXRvcnk+T3B0aW9uYWw8L01hbmRhdG9yeT4KICAgICAgICAgICAgICAgIDxUeXBlPjwvVHlwZT4KICAgICAgICAgICAgICAgIDxSYW5nZUVudW1lcmF0aW9uPjwvUmFuZ2VFbnVtZXJhdGlvbj4KICAgICAgICAgICAgICAgIDxVbml0cz48L1VuaXRzPgogICAgICAgICAgICAgICAgPERlc2NyaXB0aW9uPjwhW0NEQVRBW1BlcmZvcm0gZmFjdG9yeSByZXNldCBvZiB0aGUgTHdNMk0gRGV2aWNlIHRvIG1ha2UgdGhlIEx3TTJNIERldmljZSB0byBnbyB0aHJvdWdoIGluaXRpYWwgZGVwbG95bWVudCBzZXF1ZW5jZSB3aGVyZSBwcm92aXNpb25pbmcgYW5kIGJvb3RzdHJhcCBzZXF1ZW5jZSBpcyBwZXJmb3JtZWQuIFRoaXMgcmVxdWlyZXMgY2xpZW50IGVuc3VyaW5nIHBvc3QgZmFjdG9yeSByZXNldCB0byBoYXZlIG1pbmltYWwgaW5mb3JtYXRpb24gdG8gYWxsb3cgaXQgdG8gY2Fycnkgb3V0IG9uZSBvZiB0aGUgYm9vdHN0cmFwIG1ldGhvZHMgc3BlY2lmaWVkIGluIHNlY3Rpb24gNS4yLjMuCldoZW4gdGhpcyBSZXNvdXJjZSBpcyBleGVjdXRlZCwgIkRlLXJlZ2lzdGVyIiBvcGVyYXRpb24gTUFZIGJlIHNlbnQgdG8gdGhlIEx3TTJNIFNlcnZlcihzKSBiZWZvcmUgZmFjdG9yeSByZXNldCBvZiB0aGUgTHdNMk0gRGV2aWNlLl1dPjwvRGVzY3JpcHRpb24+CiAgICAgICAgICAgIDwvSXRlbT4KICAgICAgICAgICAgPEl0ZW0gSUQ9IjYiPgogICAgICAgICAgICAgICAgPE5hbWU+QXZhaWxhYmxlIFBvd2VyIFNvdXJjZXM8L05hbWU+CiAgICAgICAgICAgICAgICA8T3BlcmF0aW9ucz5SPC9PcGVyYXRpb25zPgogICAgICAgICAgICAgICAgPE11bHRpcGxlSW5zdGFuY2VzPk11bHRpcGxlPC9NdWx0aXBsZUluc3RhbmNlcz4KICAgICAgICAgICAgICAgIDxNYW5kYXRvcnk+T3B0aW9uYWw8L01hbmRhdG9yeT4KICAgICAgICAgICAgICAgIDxUeXBlPkludGVnZXI8L1R5cGU+CiAgICAgICAgICAgICAgICA8UmFuZ2VFbnVtZXJhdGlvbj4wLi43PC9SYW5nZUVudW1lcmF0aW9uPgogICAgICAgICAgICAgICAgPFVuaXRzPjwvVW5pdHM+CiAgICAgICAgICAgICAgICA8RGVzY3JpcHRpb24+PCFbQ0RBVEFbMDogREMgcG93ZXIKMTogSW50ZXJuYWwgQmF0dGVyeQoyOiBFeHRlcm5hbCBCYXR0ZXJ5CjM6IEZ1ZWwgQ2VsbAo0OiBQb3dlciBvdmVyIEV0aGVybmV0CjU6IFVTQgo2OiBBQyAoTWFpbnMpIHBvd2VyCjc6IFNvbGFyClRoZSBzYW1lIFJlc291cmNlIEluc3RhbmNlIElEIE1VU1QgYmUgdXNlZCB0byBhc3NvY2lhdGUgYSBnaXZlbiBQb3dlciBTb3VyY2UgKFJlc291cmNlIElEOjYpIHdpdGggaXRzIFByZXNlbnQgVm9sdGFnZSAoUmVzb3VyY2UgSUQ6NykgYW5kIGl0cyBQcmVzZW50IEN1cnJlbnQgKFJlc291cmNlIElEOjgpXV0+PC9EZXNjcmlwdGlvbj4KICAgICAgICAgICAgPC9JdGVtPgogICAgICAgICAgICA8SXRlbSBJRD0iNyI+CiAgICAgICAgICAgICAgICA8TmFtZT5Qb3dlciBTb3VyY2UgVm9sdGFnZTwvTmFtZT4KICAgICAgICAgICAgICAgIDxPcGVyYXRpb25zPlI8L09wZXJhdGlvbnM+CiAgICAgICAgICAgICAgICA8TXVsdGlwbGVJbnN0YW5jZXM+TXVsdGlwbGU8L011bHRpcGxlSW5zdGFuY2VzPgogICAgICAgICAgICAgICAgPE1hbmRhdG9yeT5PcHRpb25hbDwvTWFuZGF0b3J5PgogICAgICAgICAgICAgICAgPFR5cGU+SW50ZWdlcjwvVHlwZT4KICAgICAgICAgICAgICAgIDxSYW5nZUVudW1lcmF0aW9uPjwvUmFuZ2VFbnVtZXJhdGlvbj4KICAgICAgICAgICAgICAgIDxVbml0cz48L1VuaXRzPgogICAgICAgICAgICAgICAgPERlc2NyaXB0aW9uPjwhW0NEQVRBW1ByZXNlbnQgdm9sdGFnZSBmb3IgZWFjaCBBdmFpbGFibGUgUG93ZXIgU291cmNlcyBSZXNvdXJjZSBJbnN0YW5jZS4gVGhlIHVuaXQgdXNlZCBmb3IgdGhpcyByZXNvdXJjZSBpcyBpbiBtVi5dXT48L0Rlc2NyaXB0aW9uPgogICAgICAgICAgICA8L0l0ZW0+CiAgICAgICAgICAgIDxJdGVtIElEPSI4Ij4KICAgICAgICAgICAgICAgIDxOYW1lPlBvd2VyIFNvdXJjZSBDdXJyZW50PC9OYW1lPgogICAgICAgICAgICAgICAgPE9wZXJhdGlvbnM+UjwvT3BlcmF0aW9ucz4KICAgICAgICAgICAgICAgIDxNdWx0aXBsZUluc3RhbmNlcz5NdWx0aXBsZTwvTXVsdGlwbGVJbnN0YW5jZXM+CiAgICAgICAgICAgICAgICA8TWFuZGF0b3J5Pk9wdGlvbmFsPC9NYW5kYXRvcnk+CiAgICAgICAgICAgICAgICA8VHlwZT5JbnRlZ2VyPC9UeXBlPgogICAgICAgICAgICAgICAgPFJhbmdlRW51bWVyYXRpb24+PC9SYW5nZUVudW1lcmF0aW9uPgogICAgICAgICAgICAgICAgPFVuaXRzPjwvVW5pdHM+CiAgICAgICAgICAgICAgICA8RGVzY3JpcHRpb24+PCFbQ0RBVEFbUHJlc2VudCBjdXJyZW50IGZvciBlYWNoIEF2YWlsYWJsZSBQb3dlciBTb3VyY2UuIFRoZSB1bml0IHVzZWQgZm9yIHRoaXMgcmVzb3VyY2UgaXMgaW4gbUEuXV0+PC9EZXNjcmlwdGlvbj4KICAgICAgICAgICAgPC9JdGVtPgogICAgICAgICAgICA8SXRlbSBJRD0iOSI+CiAgICAgICAgICAgICAgICA8TmFtZT5CYXR0ZXJ5IExldmVsPC9OYW1lPgogICAgICAgICAgICAgICAgPE9wZXJhdGlvbnM+UjwvT3BlcmF0aW9ucz4KICAgICAgICAgICAgICAgIDxNdWx0aXBsZUluc3RhbmNlcz5TaW5nbGU8L011bHRpcGxlSW5zdGFuY2VzPgogICAgICAgICAgICAgICAgPE1hbmRhdG9yeT5PcHRpb25hbDwvTWFuZGF0b3J5PgogICAgICAgICAgICAgICAgPFR5cGU+SW50ZWdlcjwvVHlwZT4KICAgICAgICAgICAgICAgIDxSYW5nZUVudW1lcmF0aW9uPjAuLjEwMDwvUmFuZ2VFbnVtZXJhdGlvbj4KICAgICAgICAgICAgICAgIDxVbml0cz4vMTAwPC9Vbml0cz4KICAgICAgICAgICAgICAgIDxEZXNjcmlwdGlvbj48IVtDREFUQVtDb250YWlucyB0aGUgY3VycmVudCBiYXR0ZXJ5IGxldmVsIGFzIGEgcGVyY2VudGFnZSAod2l0aCBhIHJhbmdlIGZyb20gMCB0byAxMDApLiBUaGlzIHZhbHVlIGlzIG9ubHkgdmFsaWQgZm9yIHRoZSBEZXZpY2UgaW50ZXJuYWwgQmF0dGVyeSBpZiBwcmVzZW50IChvbmUgQXZhaWxhYmxlIFBvd2VyIFNvdXJjZXMgUmVzb3VyY2UgSW5zdGFuY2UgaXMgMSkuXV0+PC9EZXNjcmlwdGlvbj4KICAgICAgICAgICAgPC9JdGVtPgogICAgICAgICAgICA8SXRlbSBJRD0iMTAiPgogICAgICAgICAgICAgICAgPE5hbWU+TWVtb3J5IEZyZWU8L05hbWU+CiAgICAgICAgICAgICAgICA8T3BlcmF0aW9ucz5SPC9PcGVyYXRpb25zPgogICAgICAgICAgICAgICAgPE11bHRpcGxlSW5zdGFuY2VzPlNpbmdsZTwvTXVsdGlwbGVJbnN0YW5jZXM+CiAgICAgICAgICAgICAgICA8TWFuZGF0b3J5Pk9wdGlvbmFsPC9NYW5kYXRvcnk+CiAgICAgICAgICAgICAgICA8VHlwZT5JbnRlZ2VyPC9UeXBlPgogICAgICAgICAgICAgICAgPFJhbmdlRW51bWVyYXRpb24+PC9SYW5nZUVudW1lcmF0aW9uPgogICAgICAgICAgICAgICAgPFVuaXRzPjwvVW5pdHM+CiAgICAgICAgICAgICAgICA8RGVzY3JpcHRpb24+PCFbQ0RBVEFbRXN0aW1hdGVkIGN1cnJlbnQgYXZhaWxhYmxlIGFtb3VudCBvZiBzdG9yYWdlIHNwYWNlIHdoaWNoIGNhbiBzdG9yZSBkYXRhIGFuZCBzb2Z0d2FyZSBpbiB0aGUgTHdNMk0gRGV2aWNlIChleHByZXNzZWQgaW4ga2lsb2J5dGVzKS4gTm90ZTogMSBraWxvYnl0ZSBjb3JyZXNwb25kcyB0byAxMDAwIGJ5dGVzLl1dPjwvRGVzY3JpcHRpb24+CiAgICAgICAgICAgIDwvSXRlbT4KICAgICAgICAgICAgPEl0ZW0gSUQ9IjExIj4KICAgICAgICAgICAgICAgIDxOYW1lPkVycm9yIENvZGU8L05hbWU+CiAgICAgICAgICAgICAgICA8T3BlcmF0aW9ucz5SPC9PcGVyYXRpb25zPgogICAgICAgICAgICAgICAgPE11bHRpcGxlSW5zdGFuY2VzPk11bHRpcGxlPC9NdWx0aXBsZUluc3RhbmNlcz4KICAgICAgICAgICAgICAgIDxNYW5kYXRvcnk+TWFuZGF0b3J5PC9NYW5kYXRvcnk+CiAgICAgICAgICAgICAgICA8VHlwZT5JbnRlZ2VyPC9UeXBlPgogICAgICAgICAgICAgICAgPFJhbmdlRW51bWVyYXRpb24+MC4uMzI8L1JhbmdlRW51bWVyYXRpb24+CiAgICAgICAgICAgICAgICA8VW5pdHM+PC9Vbml0cz4KICAgICAgICAgICAgICAgIDxEZXNjcmlwdGlvbj48IVtDREFUQVswPU5vIGVycm9yCjE9TG93IGJhdHRlcnkgcG93ZXIKMj1FeHRlcm5hbCBwb3dlciBzdXBwbHkgb2ZmCjM9R1BTIG1vZHVsZSBmYWlsdXJlCjQ9TG93IHJlY2VpdmVkIHNpZ25hbCBzdHJlbmd0aAo1PU91dCBvZiBtZW1vcnkKNj1TTVMgZmFpbHVyZQo3PUlQIGNvbm5lY3Rpdml0eSBmYWlsdXJlCjg9UGVyaXBoZXJhbCBtYWxmdW5jdGlvbgo5Li4xNT1SZXNlcnZlZCBmb3IgZnV0dXJlIHVzZQoxNi4uMzI9RGV2aWNlIHNwZWNpZmljIGVycm9yIGNvZGVzCgpXaGVuIHRoZSBzaW5nbGUgRGV2aWNlIE9iamVjdCBJbnN0YW5jZSBpcyBpbml0aWF0ZWQsIHRoZXJlIGlzIG9ubHkgb25lIGVycm9yIGNvZGUgUmVzb3VyY2UgSW5zdGFuY2Ugd2hvc2UgdmFsdWUgaXMgZXF1YWwgdG8gMCB0aGF0IG1lYW5zIG5vIGVycm9yLiBXaGVuIHRoZSBmaXJzdCBlcnJvciBoYXBwZW5zLCB0aGUgTHdNMk0gQ2xpZW50IGNoYW5nZXMgZXJyb3IgY29kZSBSZXNvdXJjZSBJbnN0YW5jZSB0byBhbnkgbm9uLXplcm8gdmFsdWUgdG8gaW5kaWNhdGUgdGhlIGVycm9yIHR5cGUuIFdoZW4gYW55IG90aGVyIGVycm9yIGhhcHBlbnMsIGEgbmV3IGVycm9yIGNvZGUgUmVzb3VyY2UgSW5zdGFuY2UgaXMgY3JlYXRlZC4gV2hlbiBhbiBlcnJvciBhc3NvY2lhdGVkIHdpdGggYSBSZXNvdXJjZSBJbnN0YW5jZSBpcyBubyBsb25nZXIgcHJlc2VudCwgdGhhdCBSZXNvdXJjZSBJbnN0YW5jZSBpcyBkZWxldGVkLiBXaGVuIHRoZSBzaW5nbGUgZXhpc3RpbmcgZXJyb3IgaXMgbm8gbG9uZ2VyIHByZXNlbnQsIHRoZSBMd00yTSBDbGllbnQgcmV0dXJucyB0byB0aGUgb3JpZ2luYWwgbm8gZXJyb3Igc3RhdGUgd2hlcmUgSW5zdGFuY2UgMCBoYXMgdmFsdWUgMC4KVGhpcyBlcnJvciBjb2RlIFJlc291cmNlIE1BWSBiZSBvYnNlcnZlZCBieSB0aGUgTHdNMk0gU2VydmVyLiBIb3cgdG8gZGVhbCB3aXRoIEx3TTJNIENsaWVudOKAmXMgZXJyb3IgcmVwb3J0IGRlcGVuZHMgb24gdGhlIHBvbGljeSBvZiB0aGUgTHdNMk0gU2VydmVyLiBFcnJvciBjb2RlcyBpbiBiZXR3ZWVuIDE2IGFuZCAzMiBhcmUgc3BlY2lmaWMgdG8gdGhlIERldmljZSBhbmQgbWF5IGhhdmUgZGlmZmVyZW50IG1lYW5pbmdzIGFtb25nIGltcGxlbWVudGF0aW9ucy5dXT48L0Rlc2NyaXB0aW9uPgogICAgICAgICAgICA8L0l0ZW0+CiAgICAgICAgICAgIDxJdGVtIElEPSIxMiI+CiAgICAgICAgICAgICAgICA8TmFtZT5SZXNldCBFcnJvciBDb2RlPC9OYW1lPgogICAgICAgICAgICAgICAgPE9wZXJhdGlvbnM+RTwvT3BlcmF0aW9ucz4KICAgICAgICAgICAgICAgIDxNdWx0aXBsZUluc3RhbmNlcz5TaW5nbGU8L011bHRpcGxlSW5zdGFuY2VzPgogICAgICAgICAgICAgICAgPE1hbmRhdG9yeT5PcHRpb25hbDwvTWFuZGF0b3J5PgogICAgICAgICAgICAgICAgPFR5cGU+PC9UeXBlPgogICAgICAgICAgICAgICAgPFJhbmdlRW51bWVyYXRpb24+PC9SYW5nZUVudW1lcmF0aW9uPgogICAgICAgICAgICAgICAgPFVuaXRzPjwvVW5pdHM+CiAgICAgICAgICAgICAgICA8RGVzY3JpcHRpb24+PCFbQ0RBVEFbRGVsZXRlIGFsbCBlcnJvciBjb2RlIFJlc291cmNlIEluc3RhbmNlcyBhbmQgY3JlYXRlIG9ubHkgb25lIHplcm8tdmFsdWUgZXJyb3IgY29kZSB0aGF0IGltcGxpZXMgbm8gZXJyb3IsIHRoZW4gcmUtZXZhbHVhdGUgYWxsIGVycm9yIGNvbmRpdGlvbnMgYW5kIHVwZGF0ZSBhbmQgY3JlYXRlIFJlc291cmNlcyBJbnN0YW5jZXMgdG8gY2FwdHVyZSBhbGwgY3VycmVudCBlcnJvciBjb25kaXRpb25zLl1dPjwvRGVzY3JpcHRpb24+CiAgICAgICAgICAgIDwvSXRlbT4KICAgICAgICAgICAgPEl0ZW0gSUQ9IjEzIj4KICAgICAgICAgICAgICAgIDxOYW1lPkN1cnJlbnQgVGltZTwvTmFtZT4KICAgICAgICAgICAgICAgIDxPcGVyYXRpb25zPlJXPC9PcGVyYXRpb25zPgogICAgICAgICAgICAgICAgPE11bHRpcGxlSW5zdGFuY2VzPlNpbmdsZTwvTXVsdGlwbGVJbnN0YW5jZXM+CiAgICAgICAgICAgICAgICA8TWFuZGF0b3J5Pk9wdGlvbmFsPC9NYW5kYXRvcnk+CiAgICAgICAgICAgICAgICA8VHlwZT5UaW1lPC9UeXBlPgogICAgICAgICAgICAgICAgPFJhbmdlRW51bWVyYXRpb24+PC9SYW5nZUVudW1lcmF0aW9uPgogICAgICAgICAgICAgICAgPFVuaXRzPjwvVW5pdHM+CiAgICAgICAgICAgICAgICA8RGVzY3JpcHRpb24+PCFbQ0RBVEFbQ3VycmVudCBVTklYIHRpbWUgb2YgdGhlIEx3TTJNIENsaWVudC4KVGhlIEx3TTJNIENsaWVudCBzaG91bGQgYmUgcmVzcG9uc2libGUgdG8gaW5jcmVhc2UgdGhpcyB0aW1lIHZhbHVlIGFzIGV2ZXJ5IHNlY29uZCBlbGFwc2VzLgpUaGUgTHdNMk0gU2VydmVyIGlzIGFibGUgdG8gd3JpdGUgdGhpcyBSZXNvdXJjZSB0byBtYWtlIHRoZSBMd00yTSBDbGllbnQgc3luY2hyb25pemVkIHdpdGggdGhlIEx3TTJNIFNlcnZlci5dXT48L0Rlc2NyaXB0aW9uPgogICAgICAgICAgICA8L0l0ZW0+CiAgICAgICAgICAgIDxJdGVtIElEPSIxNCI+CiAgICAgICAgICAgICAgICA8TmFtZT5VVEMgT2Zmc2V0PC9OYW1lPgogICAgICAgICAgICAgICAgPE9wZXJhdGlvbnM+Ulc8L09wZXJhdGlvbnM+CiAgICAgICAgICAgICAgICA8TXVsdGlwbGVJbnN0YW5jZXM+U2luZ2xlPC9NdWx0aXBsZUluc3RhbmNlcz4KICAgICAgICAgICAgICAgIDxNYW5kYXRvcnk+T3B0aW9uYWw8L01hbmRhdG9yeT4KICAgICAgICAgICAgICAgIDxUeXBlPlN0cmluZzwvVHlwZT4KICAgICAgICAgICAgICAgIDxSYW5nZUVudW1lcmF0aW9uPjwvUmFuZ2VFbnVtZXJhdGlvbj4KICAgICAgICAgICAgICAgIDxVbml0cz48L1VuaXRzPgogICAgICAgICAgICAgICAgPERlc2NyaXB0aW9uPjwhW0NEQVRBW0luZGljYXRlcyB0aGUgVVRDIG9mZnNldCBjdXJyZW50bHkgaW4gZWZmZWN0IGZvciB0aGlzIEx3TTJNIERldmljZS4gVVRDK1ggW0lTTyA4NjAxXS5dXT48L0Rlc2NyaXB0aW9uPgogICAgICAgICAgICA8L0l0ZW0+CiAgICAgICAgICAgIDxJdGVtIElEPSIxNSI+CiAgICAgICAgICAgICAgICA8TmFtZT5UaW1lem9uZTwvTmFtZT4KICAgICAgICAgICAgICAgIDxPcGVyYXRpb25zPlJXPC9PcGVyYXRpb25zPgogICAgICAgICAgICAgICAgPE11bHRpcGxlSW5zdGFuY2VzPlNpbmdsZTwvTXVsdGlwbGVJbnN0YW5jZXM+CiAgICAgICAgICAgICAgICA8TWFuZGF0b3J5Pk9wdGlvbmFsPC9NYW5kYXRvcnk+CiAgICAgICAgICAgICAgICA8VHlwZT5TdHJpbmc8L1R5cGU+CiAgICAgICAgICAgICAgICA8UmFuZ2VFbnVtZXJhdGlvbj48L1JhbmdlRW51bWVyYXRpb24+CiAgICAgICAgICAgICAgICA8VW5pdHM+PC9Vbml0cz4KICAgICAgICAgICAgICAgIDxEZXNjcmlwdGlvbj48IVtDREFUQVtJbmRpY2F0ZXMgaW4gd2hpY2ggdGltZSB6b25lIHRoZSBMd00yTSBEZXZpY2UgaXMgbG9jYXRlZCwgaW4gSUFOQSBUaW1lem9uZSAoVFopIGRhdGFiYXNlIGZvcm1hdC5dXT48L0Rlc2NyaXB0aW9uPgogICAgICAgICAgICA8L0l0ZW0+CiAgICAgICAgICAgIDxJdGVtIElEPSIxNiI+CiAgICAgICAgICAgICAgICA8TmFtZT5TdXBwb3J0ZWQgQmluZGluZyBhbmQgTW9kZXM8L05hbWU+CiAgICAgICAgICAgICAgICA8T3BlcmF0aW9ucz5SPC9PcGVyYXRpb25zPgogICAgICAgICAgICAgICAgPE11bHRpcGxlSW5zdGFuY2VzPlNpbmdsZTwvTXVsdGlwbGVJbnN0YW5jZXM+CiAgICAgICAgICAgICAgICA8TWFuZGF0b3J5Pk1hbmRhdG9yeTwvTWFuZGF0b3J5PgogICAgICAgICAgICAgICAgPFR5cGU+U3RyaW5nPC9UeXBlPgogICAgICAgICAgICAgICAgPFJhbmdlRW51bWVyYXRpb24+PC9SYW5nZUVudW1lcmF0aW9uPgogICAgICAgICAgICAgICAgPFVuaXRzPjwvVW5pdHM+CiAgICAgICAgICAgICAgICA8RGVzY3JpcHRpb24+PCFbQ0RBVEFbSW5kaWNhdGVzIHdoaWNoIGJpbmRpbmdzIGFuZCBtb2RlcyBhcmUgc3VwcG9ydGVkIGluIHRoZSBMd00yTSBDbGllbnQuIFRoZSBwb3NzaWJsZSB2YWx1ZXMgYXJlIHRob3NlIGxpc3RlZCBpbiB0aGUgTHdNMk0gQ29yZSBTcGVjaWZpY2F0aW9uLl1dPjwvRGVzY3JpcHRpb24+CiAgICAgICAgICAgIDwvSXRlbT4KICAgICAgICAgICAgPEl0ZW0gSUQ9IjE3Ij48TmFtZT5EZXZpY2UgVHlwZTwvTmFtZT4KICAgICAgICAgICAgICAgIDxPcGVyYXRpb25zPlI8L09wZXJhdGlvbnM+CiAgICAgICAgICAgICAgICA8TXVsdGlwbGVJbnN0YW5jZXM+U2luZ2xlPC9NdWx0aXBsZUluc3RhbmNlcz4KICAgICAgICAgICAgICAgIDxNYW5kYXRvcnk+T3B0aW9uYWw8L01hbmRhdG9yeT4KICAgICAgICAgICAgICAgIDxUeXBlPlN0cmluZzwvVHlwZT4KICAgICAgICAgICAgICAgIDxSYW5nZUVudW1lcmF0aW9uPjwvUmFuZ2VFbnVtZXJhdGlvbj4KICAgICAgICAgICAgICAgIDxVbml0cz48L1VuaXRzPgogICAgICAgICAgICAgICAgPERlc2NyaXB0aW9uPjwhW0NEQVRBW1R5cGUgb2YgdGhlIGRldmljZSAobWFudWZhY3R1cmVyIHNwZWNpZmllZCBzdHJpbmc6IGUuZy4gc21hcnQgbWV0ZXJzIC8gZGV2IENsYXNzIC8gLi4uKV1dPjwvRGVzY3JpcHRpb24+CiAgICAgICAgICAgIDwvSXRlbT4KICAgICAgICAgICAgPEl0ZW0gSUQ9IjE4Ij48TmFtZT5IYXJkd2FyZSBWZXJzaW9uPC9OYW1lPgogICAgICAgICAgICAgICAgPE9wZXJhdGlvbnM+UjwvT3BlcmF0aW9ucz4KICAgICAgICAgICAgICAgIDxNdWx0aXBsZUluc3RhbmNlcz5TaW5nbGU8L011bHRpcGxlSW5zdGFuY2VzPgogICAgICAgICAgICAgICAgPE1hbmRhdG9yeT5PcHRpb25hbDwvTWFuZGF0b3J5PgogICAgICAgICAgICAgICAgPFR5cGU+U3RyaW5nPC9UeXBlPgogICAgICAgICAgICAgICAgPFJhbmdlRW51bWVyYXRpb24+PC9SYW5nZUVudW1lcmF0aW9uPgogICAgICAgICAgICAgICAgPFVuaXRzPjwvVW5pdHM+CiAgICAgICAgICAgICAgICA8RGVzY3JpcHRpb24+PCFbQ0RBVEFbQ3VycmVudCBoYXJkd2FyZSB2ZXJzaW9uIG9mIHRoZSBkZXZpY2VdXT48L0Rlc2NyaXB0aW9uPgogICAgICAgICAgICA8L0l0ZW0+CiAgICAgICAgICAgIDxJdGVtIElEPSIxOSI+PE5hbWU+U29mdHdhcmUgVmVyc2lvbjwvTmFtZT4KICAgICAgICAgICAgICAgIDxPcGVyYXRpb25zPlI8L09wZXJhdGlvbnM+CiAgICAgICAgICAgICAgICA8TXVsdGlwbGVJbnN0YW5jZXM+U2luZ2xlPC9NdWx0aXBsZUluc3RhbmNlcz4KICAgICAgICAgICAgICAgIDxNYW5kYXRvcnk+T3B0aW9uYWw8L01hbmRhdG9yeT4KICAgICAgICAgICAgICAgIDxUeXBlPlN0cmluZzwvVHlwZT4KICAgICAgICAgICAgICAgIDxSYW5nZUVudW1lcmF0aW9uPjwvUmFuZ2VFbnVtZXJhdGlvbj4KICAgICAgICAgICAgICAgIDxVbml0cz48L1VuaXRzPgogICAgICAgICAgICAgICAgPERlc2NyaXB0aW9uPjwhW0NEQVRBW0N1cnJlbnQgc29mdHdhcmUgdmVyc2lvbiBvZiB0aGUgZGV2aWNlIChtYW51ZmFjdHVyZXIgc3BlY2lmaWVkIHN0cmluZykuIE9uIGVsYWJvcmF0ZWQgTHdNMk0gZGV2aWNlLCBTVyBjb3VsZCBiZSBzcGxpdCBpbiAyIHBhcnRzOiBhIGZpcm13YXJlIG9uZSBhbmQgYSBoaWdoZXIgbGV2ZWwgc29mdHdhcmUgb24gdG9wLgpCb3RoIHBpZWNlcyBvZiBTb2Z0d2FyZSBhcmUgdG9nZXRoZXIgbWFuYWdlZCBieSBMd00yTSBGaXJtd2FyZSBVcGRhdGUgT2JqZWN0IChPYmplY3QgSUQgNSldXT48L0Rlc2NyaXB0aW9uPgogICAgICAgICAgICA8L0l0ZW0+CiAgICAgICAgICAgIDxJdGVtIElEPSIyMCI+PE5hbWU+QmF0dGVyeSBTdGF0dXM8L05hbWU+CiAgICAgICAgICAgICAgICA8T3BlcmF0aW9ucz5SPC9PcGVyYXRpb25zPgogICAgICAgICAgICAgICAgPE11bHRpcGxlSW5zdGFuY2VzPlNpbmdsZTwvTXVsdGlwbGVJbnN0YW5jZXM+CiAgICAgICAgICAgICAgICA8TWFuZGF0b3J5Pk9wdGlvbmFsPC9NYW5kYXRvcnk+CiAgICAgICAgICAgICAgICA8VHlwZT5JbnRlZ2VyPC9UeXBlPgogICAgICAgICAgICAgICAgPFJhbmdlRW51bWVyYXRpb24+MC4uNjwvUmFuZ2VFbnVtZXJhdGlvbj4KICAgICAgICAgICAgICAgIDxVbml0cz48L1VuaXRzPgogICAgICAgICAgICAgICAgPERlc2NyaXB0aW9uPjwhW0NEQVRBW1RoaXMgdmFsdWUgaXMgb25seSB2YWxpZCBmb3IgdGhlIERldmljZSBJbnRlcm5hbCBCYXR0ZXJ5IGlmIHByZXNlbnQgKG9uZSBBdmFpbGFibGUgUG93ZXIgU291cmNlcyBSZXNvdXJjZSBJbnN0YW5jZSB2YWx1ZSBpcyAxKS4KQmF0dGVyeQpTdGF0dXMJTWVhbmluZwlEZXNjcmlwdGlvbgowCU5vcm1hbAlUaGUgYmF0dGVyeSBpcyBvcGVyYXRpbmcgbm9ybWFsbHkgYW5kIG5vdCBvbiBwb3dlci4KMQlDaGFyZ2luZwlUaGUgYmF0dGVyeSBpcyBjdXJyZW50bHkgY2hhcmdpbmcuCjIJQ2hhcmdlIENvbXBsZXRlCVRoZSBiYXR0ZXJ5IGlzIGZ1bGx5IGNoYXJnZWQgYW5kIHN0aWxsIG9uIHBvd2VyLgozCURhbWFnZWQJVGhlIGJhdHRlcnkgaGFzIHNvbWUgcHJvYmxlbS4KNAlMb3cgQmF0dGVyeQlUaGUgYmF0dGVyeSBpcyBsb3cgb24gY2hhcmdlLgo1CU5vdCBJbnN0YWxsZWQJVGhlIGJhdHRlcnkgaXMgbm90IGluc3RhbGxlZC4KNglVbmtub3duCVRoZSBiYXR0ZXJ5IGluZm9ybWF0aW9uIGlzIG5vdCBhdmFpbGFibGUuXV0+PC9EZXNjcmlwdGlvbj4KICAgICAgICAgICAgPC9JdGVtPgogICAgICAgICAgICA8SXRlbSBJRD0iMjEiPjxOYW1lPk1lbW9yeSBUb3RhbDwvTmFtZT4KICAgICAgICAgICAgICAgIDxPcGVyYXRpb25zPlI8L09wZXJhdGlvbnM+CiAgICAgICAgICAgICAgICA8TXVsdGlwbGVJbnN0YW5jZXM+U2luZ2xlPC9NdWx0aXBsZUluc3RhbmNlcz4KICAgICAgICAgICAgICAgIDxNYW5kYXRvcnk+T3B0aW9uYWw8L01hbmRhdG9yeT4KICAgICAgICAgICAgICAgIDxUeXBlPkludGVnZXI8L1R5cGU+CiAgICAgICAgICAgICAgICA8UmFuZ2VFbnVtZXJhdGlvbj48L1JhbmdlRW51bWVyYXRpb24+CiAgICAgICAgICAgICAgICA8VW5pdHM+PC9Vbml0cz4KICAgICAgICAgICAgICAgIDxEZXNjcmlwdGlvbj48IVtDREFUQVtUb3RhbCBhbW91bnQgb2Ygc3RvcmFnZSBzcGFjZSB3aGljaCBjYW4gc3RvcmUgZGF0YSBhbmQgc29mdHdhcmUgaW4gdGhlIEx3TTJNIERldmljZSAoZXhwcmVzc2VkIGluIGtpbG9ieXRlcykuICBOb3RlOiAxIGtpbG9ieXRlIGNvcnJlc3BvbmRzIHRvIDEwMDAgYnl0ZXMuXV0+PC9EZXNjcmlwdGlvbj4KICAgICAgICAgICAgPC9JdGVtPgogICAgICAgICAgICA8SXRlbSBJRD0iMjIiPjxOYW1lPkV4dERldkluZm88L05hbWU+CiAgICAgICAgICAgICAgICA8T3BlcmF0aW9ucz5SPC9PcGVyYXRpb25zPgogICAgICAgICAgICAgICAgPE11bHRpcGxlSW5zdGFuY2VzPk11bHRpcGxlPC9NdWx0aXBsZUluc3RhbmNlcz4KICAgICAgICAgICAgICAgIDxNYW5kYXRvcnk+T3B0aW9uYWw8L01hbmRhdG9yeT4KICAgICAgICAgICAgICAgIDxUeXBlPk9iamxuazwvVHlwZT4KICAgICAgICAgICAgICAgIDxSYW5nZUVudW1lcmF0aW9uPjwvUmFuZ2VFbnVtZXJhdGlvbj4KICAgICAgICAgICAgICAgIDxVbml0cz48L1VuaXRzPgogICAgICAgICAgICAgICAgPERlc2NyaXB0aW9uPjwhW0NEQVRBW1JlZmVyZW5jZSB0byBleHRlcm5hbCAiRGV2aWNlIiBvYmplY3QgaW5zdGFuY2UgY29udGFpbmluZyBpbmZvcm1hdGlvbi4gRm9yIGV4YW1wbGUsIHN1Y2ggYW4gZXh0ZXJuYWwgZGV2aWNlIGNhbiBiZSBhIEhvc3QgRGV2aWNlLCB3aGljaCBpcyBhIGRldmljZSBpbnRvIHdoaWNoIHRoZSBEZXZpY2UgY29udGFpbmluZyB0aGUgTHdNMk0gY2xpZW50IGlzIGVtYmVkZGVkLiBUaGlzIFJlc291cmNlIG1heSBiZSB1c2VkIHRvIHJldHJpZXZlIGluZm9ybWF0aW9uIGFib3V0IHRoZSBIb3N0IERldmljZS5dXT48L0Rlc2NyaXB0aW9uPgogICAgICAgICAgICA8L0l0ZW0+PC9SZXNvdXJjZXM+CiAgICAgICAgPERlc2NyaXB0aW9uMj48L0Rlc2NyaXB0aW9uMj4KICAgIDwvT2JqZWN0Pgo8L0xXTTJNPgo=" } \ No newline at end of file diff --git a/monitoring/src/main/resources/root_rule_chain.json b/monitoring/src/main/resources/root_rule_chain.json index 46bdc72d9f..a1c12c8e9d 100644 --- a/monitoring/src/main/resources/root_rule_chain.json +++ b/monitoring/src/main/resources/root_rule_chain.json @@ -39,8 +39,11 @@ "type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode", "name": "Save Attributes", "singletonMode": false, - "configurationVersion": 1, + "configurationVersion": 3, "configuration": { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, "scope": "CLIENT_SCOPE", "notifyDevice": false, "sendAttributesUpdatedNotification": false, diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java index 3c067ee641..65def6a964 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java @@ -51,6 +51,7 @@ public class ContainerTestSuite { private static final String TB_CORE_LOG_REGEXP = ".*Starting polling for events.*"; private static final String TRANSPORTS_LOG_REGEXP = ".*Going to recalculate partitions.*"; private static final String TB_VC_LOG_REGEXP = TRANSPORTS_LOG_REGEXP; + private static final String TB_EDQS_LOG_REGEXP = ".*All partitions processed.*"; private static final String TB_JS_EXECUTOR_LOG_REGEXP = ".*template started.*"; private static final Duration CONTAINER_STARTUP_TIMEOUT = Duration.ofSeconds(400); @@ -114,6 +115,8 @@ public class ContainerTestSuite { List composeFiles = new ArrayList<>(Arrays.asList( new File(targetDir + "docker-compose.yml"), + new File(targetDir + "docker-compose.edqs.yml"), + new File(targetDir + "docker-compose.edqs.volumes.yml"), new File(targetDir + "docker-compose.volumes.yml"), new File(targetDir + "docker-compose.mosquitto.yml"), new File(targetDir + (IS_HYBRID_MODE ? "docker-compose.hybrid.yml" : "docker-compose.postgres.yml")), @@ -174,6 +177,8 @@ public class ContainerTestSuite { .withExposedService("broker", 1883) .waitingFor("tb-core1", Wait.forLogMessage(TB_CORE_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) .waitingFor("tb-core2", Wait.forLogMessage(TB_CORE_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) + .waitingFor("tb-rule-engine1", Wait.forLogMessage(TRANSPORTS_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) + .waitingFor("tb-rule-engine2", Wait.forLogMessage(TRANSPORTS_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) .waitingFor("tb-http-transport1", Wait.forLogMessage(TRANSPORTS_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) .waitingFor("tb-http-transport2", Wait.forLogMessage(TRANSPORTS_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) .waitingFor("tb-mqtt-transport1", Wait.forLogMessage(TRANSPORTS_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) @@ -182,7 +187,9 @@ public class ContainerTestSuite { .waitingFor("tb-lwm2m-transport", Wait.forLogMessage(TRANSPORTS_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) .waitingFor("tb-vc-executor1", Wait.forLogMessage(TB_VC_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) .waitingFor("tb-vc-executor2", Wait.forLogMessage(TB_VC_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) - .waitingFor("tb-js-executor", Wait.forLogMessage(TB_JS_EXECUTOR_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)); + .waitingFor("tb-js-executor", Wait.forLogMessage(TB_JS_EXECUTOR_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) + .waitingFor("tb-edqs-1", Wait.forLogMessage(TB_EDQS_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) + .waitingFor("tb-edqs-2", Wait.forLogMessage(TB_EDQS_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)); testContainer.start(); setActive(true); } catch (Exception e) { diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java index 94d0c64fa0..aafe93f9f3 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java @@ -28,6 +28,7 @@ import io.restassured.internal.ValidatableResponseImpl; import io.restassured.path.json.JsonPath; import io.restassured.response.ValidatableResponse; import io.restassured.specification.RequestSpecification; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.Device; @@ -35,14 +36,17 @@ import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EventInfo; import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.event.EventType; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.DeviceId; @@ -56,6 +60,9 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.rpc.Rpc; @@ -66,7 +73,6 @@ import org.thingsboard.server.common.data.security.DeviceCredentials; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.UUID; import static io.restassured.RestAssured.given; import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; @@ -110,6 +116,20 @@ public class TestRestClient { requestSpec.header(JWT_TOKEN_HEADER_PARAM, "Bearer " + token); } + public void resetToken() { + token = null; + refreshToken = null; + } + + public Tenant postTenant(Tenant tenant) { + return given().spec(requestSpec).body(tenant) + .post("/api/tenant") + .then() + .statusCode(HTTP_OK) + .extract() + .as(Tenant.class); + } + public Device postDevice(String accessToken, Device device) { return given().spec(requestSpec).body(device) .pathParams("accessToken", accessToken) @@ -129,6 +149,14 @@ public class TestRestClient { .as(ObjectNode.class); } + public CalculatedField postCalculatedField(CalculatedField calculatedField) { + return given().spec(requestSpec).body(calculatedField) + .post("/api/calculatedField") + .then() + .statusCode(HTTP_OK) + .extract() + .as(CalculatedField.class); + } public Device getDeviceByName(String deviceName) { return given().spec(requestSpec).pathParam("deviceName", deviceName) @@ -195,9 +223,16 @@ public class TestRestClient { .statusCode(anyOf(is(HTTP_OK), is(HTTP_NOT_FOUND))); } - public ValidatableResponse postTelemetryAttribute(String entityType, DeviceId deviceId, String scope, JsonNode attribute) { + public ValidatableResponse deleteCalculatedFieldIfExists(CalculatedFieldId calculatedFieldId) { + return given().spec(requestSpec) + .delete("/api/calculatedField/{calculatedFieldId}", calculatedFieldId.getId()) + .then() + .statusCode(anyOf(is(HTTP_OK), is(HTTP_NOT_FOUND))); + } + + public ValidatableResponse postTelemetryAttribute(EntityId entityId, String scope, JsonNode attribute) { return given().spec(requestSpec).body(attribute) - .post("/api/plugins/telemetry/{entityType}/{entityId}/attributes/{scope}", entityType, deviceId.getId(), scope) + .post("/api/plugins/telemetry/{entityType}/{entityId}/attributes/{scope}", entityId.getEntityType(), entityId.getId(), scope) .then() .statusCode(HTTP_OK); } @@ -220,6 +255,24 @@ public class TestRestClient { .as(JsonNode.class); } + public JsonNode getAttributes(EntityId entityId, AttributeScope scope, String keys) { + return given().spec(requestSpec) + .get("/api/plugins/telemetry/{entityType}/{entityId}/values/attributes/{scope}?keys={keys}", entityId.getEntityType(), entityId.getId(), scope, keys) + .then() + .statusCode(HTTP_OK) + .extract() + .as(JsonNode.class); + } + + public JsonNode getLatestTelemetry(EntityId entityId) { + return given().spec(requestSpec) + .get("/api/plugins/telemetry/" + entityId.getEntityType().name() + "/" + entityId.getId() + "/values/timeseries") + .then() + .statusCode(HTTP_OK) + .extract() + .as(JsonNode.class); + } + public JsonPath postProvisionRequest(String provisionRequest) { return given().spec(requestSpec) .body(provisionRequest) @@ -479,6 +532,28 @@ public class TestRestClient { .as(User.class); } + public UserId createUserAndLogin(User user, String password) { + UserId userId = postUser(user).getId(); + getAndSetUserToken(userId); + return userId; + } + + public void getAndSetUserToken(UserId id) { + ObjectNode tokenInfo = given().spec(requestSpec) + .get("/api/user/" + id.getId().toString() + "/token") + .then() + .extract() + .as(ObjectNode.class); + token = tokenInfo.get("token").asText(); + refreshToken = tokenInfo.get("refreshToken").asText(); + requestSpec.header(JWT_TOKEN_HEADER_PARAM, "Bearer " + token); + } + + protected void resetTokens() { + this.token = null; + this.refreshToken = null; + } + public void deleteUser(UserId userId) { given().spec(requestSpec) .delete("/api/user/{userId}", userId.getId()) @@ -592,6 +667,7 @@ public class TestRestClient { .then() .statusCode(anyOf(is(HTTP_OK), is(HTTP_BAD_REQUEST))); } + public void deleteDeviceProfileIfExists(DeviceProfile deviceProfile) { given().spec(requestSpec) .delete("/api/deviceProfile/" + deviceProfile.getId().getId().toString()) @@ -605,11 +681,11 @@ public class TestRestClient { .get("/api/tenant/devices?deviceName={deviceName}") .then() .statusCode(anyOf(is(HTTP_OK), is(HTTP_NOT_FOUND))); - if(((ValidatableResponseImpl) response).extract().response().getStatusCode()==HTTP_OK){ - return response.extract() + if (((ValidatableResponseImpl) response).extract().response().getStatusCode() == HTTP_OK) { + return response.extract() .as(Device.class); } else { - return null; + return null; } } @@ -643,4 +719,45 @@ public class TestRestClient { } return urlParams; } + + public PageData postEntityDataQuery(EntityDataQuery entityDataQuery) { + return given().spec(requestSpec).body(entityDataQuery) + .post("/api/entitiesQuery/find") + .then() + .statusCode(HTTP_OK) + .extract() + .as(new TypeRef<>() {}); + } + + public Long postCountDataQuery(EntityCountQuery entityCountQuery) { + return given().spec(requestSpec).body(entityCountQuery) + .post("/api/entitiesQuery/count") + .then() + .statusCode(HTTP_OK) + .extract() + .as(Long.class); + } + + public Boolean isEdqsApiEnabled() { + return given().spec(requestSpec) + .get("/api/edqs/enabled") + .then() + .statusCode(HTTP_OK) + .extract() + .as(Boolean.class); + } + + public void assignDeviceToCustomer(CustomerId customerId, DeviceId id) { + given().spec(requestSpec) + .post("/api/customer/" + customerId.getId().toString() + "/device/" + id.getId().toString()) + .then() + .statusCode(HTTP_OK); + } + + public void deleteTenant(TenantId tenantId) { + given().spec(requestSpec) + .delete("/api/tenant/" + tenantId.getId().toString()) + .then() + .statusCode(HTTP_OK); + } } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java index a71abe1781..9c1a90a4f1 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java @@ -48,6 +48,7 @@ public class ThingsBoardDbInstaller { private final static String TB_MQTT_TRANSPORT_LOG_VOLUME = "tb-mqtt-transport-log-test-volume"; private final static String TB_SNMP_TRANSPORT_LOG_VOLUME = "tb-snmp-transport-log-test-volume"; private final static String TB_VC_EXECUTOR_LOG_VOLUME = "tb-vc-executor-log-test-volume"; + private final static String TB_EDQS_LOG_VOLUME = "tb-edqs-log-test-volume"; private final static String JAVA_OPTS = "-Xmx512m"; private final DockerComposeExecutor dockerCompose; @@ -65,6 +66,7 @@ public class ThingsBoardDbInstaller { private final String tbMqttTransportLogVolume; private final String tbSnmpTransportLogVolume; private final String tbVcExecutorLogVolume; + private final String tbEdqsLogVolume; private final Map env; public ThingsBoardDbInstaller() { @@ -103,6 +105,7 @@ public class ThingsBoardDbInstaller { tbMqttTransportLogVolume = project + "_" + TB_MQTT_TRANSPORT_LOG_VOLUME; tbSnmpTransportLogVolume = project + "_" + TB_SNMP_TRANSPORT_LOG_VOLUME; tbVcExecutorLogVolume = project + "_" + TB_VC_EXECUTOR_LOG_VOLUME; + tbEdqsLogVolume = project + "_" + TB_EDQS_LOG_VOLUME; dockerCompose = new DockerComposeExecutor(composeFiles, project); @@ -119,6 +122,7 @@ public class ThingsBoardDbInstaller { env.put("TB_MQTT_TRANSPORT_LOG_VOLUME", tbMqttTransportLogVolume); env.put("TB_SNMP_TRANSPORT_LOG_VOLUME", tbSnmpTransportLogVolume); env.put("TB_VC_EXECUTOR_LOG_VOLUME", tbVcExecutorLogVolume); + env.put("TB_EDQS_LOG_VOLUME", tbEdqsLogVolume); if (IS_REDIS_CLUSTER) { for (int i = 0; i < 6; i++) { env.put("REDIS_CLUSTER_DATA_VOLUME_" + i, redisClusterDataVolume + '-' + i); @@ -189,6 +193,9 @@ public class ThingsBoardDbInstaller { dockerCompose.withCommand("volume create " + tbVcExecutorLogVolume); dockerCompose.invokeDocker(); + dockerCompose.withCommand("volume create " + tbEdqsLogVolume); + dockerCompose.invokeDocker(); + StringBuilder additionalServices = new StringBuilder(); if (IS_HYBRID_MODE) { additionalServices.append(" cassandra"); @@ -220,7 +227,8 @@ public class ThingsBoardDbInstaller { dockerCompose.withCommand("up -d postgres" + additionalServices); dockerCompose.invokeCompose(); - dockerCompose.withCommand("run --no-deps --rm -e INSTALL_TB=true -e LOAD_DEMO=true tb-core1"); + dockerCompose.withCommand("run --no-deps --rm -e INSTALL_TB=true -e LOAD_DEMO=true " + + "tb-core1"); dockerCompose.invokeCompose(); } finally { @@ -240,6 +248,7 @@ public class ThingsBoardDbInstaller { copyLogs(tbMqttTransportLogVolume, "./target/tb-mqtt-transport-logs/"); copyLogs(tbSnmpTransportLogVolume, "./target/tb-snmp-transport-logs/"); copyLogs(tbVcExecutorLogVolume, "./target/tb-vc-executor-logs/"); + copyLogs(tbEdqsLogVolume, "./target/tb-edqs-logs/"); StringJoiner rmVolumesCommand = new StringJoiner(" ") .add("volume rm -f") @@ -251,6 +260,7 @@ public class ThingsBoardDbInstaller { .add(tbMqttTransportLogVolume) .add(tbSnmpTransportLogVolume) .add(tbVcExecutorLogVolume) + .add(tbEdqsLogVolume) .add(resolveRedisComposeVolumeLog()); if (IS_HYBRID_MODE) { diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java new file mode 100644 index 0000000000..63faed41ab --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java @@ -0,0 +1,368 @@ +/** + * 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. + */ +package org.thingsboard.server.msa.cf; + +import com.fasterxml.jackson.databind.JsonNode; +import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.debug.DebugSettings; +import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration; +import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; +import org.thingsboard.server.common.data.device.data.DeviceData; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.msa.AbstractContainerTest; +import org.thingsboard.server.msa.ui.utils.EntityPrototypes; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.thingsboard.server.msa.ui.utils.EntityPrototypes.defaultAssetProfile; +import static org.thingsboard.server.msa.ui.utils.EntityPrototypes.defaultDeviceProfile; +import static org.thingsboard.server.msa.ui.utils.EntityPrototypes.defaultTenantAdmin; + +public class CalculatedFieldTest extends AbstractContainerTest { + + public final int TIMEOUT = 60; + public final int POLL_INTERVAL = 1; + + private final String deviceToken = "zmzURIVRsq3lvnTP2XBE"; + + private final String exampleScript = "var avgTemperature = temperature.mean(); // Get average temperature\n" + + " var temperatureK = (avgTemperature - 32) * (5 / 9) + 273.15; // Convert Fahrenheit to Kelvin\n" + + "\n" + + " // Estimate air pressure based on altitude\n" + + " var pressure = 101325 * Math.pow((1 - 2.25577e-5 * altitude), 5.25588);\n" + + "\n" + + " // Air density formula\n" + + " var airDensity = pressure / (287.05 * temperatureK);\n" + + "\n" + + " return {\n" + + " \"airDensity\": airDensity\n" + + " };"; + + private TenantId tenantId; + private UserId tenantAdminId; + private DeviceProfileId deviceProfileId; + private AssetProfileId assetProfileId; + private Device device; + private Asset asset; + + @BeforeClass + public void beforeClass() { + testRestClient.login("sysadmin@thingsboard.org", "sysadmin"); + + tenantId = testRestClient.postTenant(EntityPrototypes.defaultTenantPrototype("Tenant")).getId(); + tenantAdminId = testRestClient.createUserAndLogin(defaultTenantAdmin(tenantId, "tenantAdmin@thingsboard.org"), "tenant"); + + deviceProfileId = testRestClient.postDeviceProfile(defaultDeviceProfile("Device Profile 1")).getId(); + device = testRestClient.postDevice(deviceToken, createDevice("Device 1", deviceProfileId)); + + assetProfileId = testRestClient.postAssetProfile(defaultAssetProfile("Asset Profile 1")).getId(); + asset = testRestClient.postAsset(createAsset("Asset 1", assetProfileId)); + + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}")); + testRestClient.postTelemetryAttribute(device.getId(), DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"deviceTemperature\":40}")); + + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperatureInF\":72.32}")); + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperatureInF\":72.86}")); + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperatureInF\":73.58}")); + + testRestClient.postTelemetryAttribute(asset.getId(), DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"altitude\":1035}")); + } + + @BeforeMethod + public void beforeMethod() { + testRestClient.login("sysadmin@thingsboard.org", "sysadmin"); + } + + @AfterClass + public void afterClass() { + testRestClient.resetToken(); + testRestClient.login("sysadmin@thingsboard.org", "sysadmin"); + testRestClient.deleteTenant(tenantId); + } + + @Test + public void testPerformInitialCalculationForSimpleType() { + // login tenant admin + testRestClient.getAndSetUserToken(tenantAdminId); + + CalculatedField savedCalculatedField = createSimpleCalculatedField(); + + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}")); + + await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0"); + }); + + testRestClient.deleteCalculatedFieldIfExists(savedCalculatedField.getId()); + } + + @Test + public void testChangeConfigArgument() { + // login tenant admin + testRestClient.getAndSetUserToken(tenantAdminId); + + CalculatedField savedCalculatedField = createSimpleCalculatedField(); + + Argument savedArgument = savedCalculatedField.getConfiguration().getArguments().get("T"); + savedArgument.setRefEntityKey(new ReferencedEntityKey("deviceTemperature", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + testRestClient.postCalculatedField(savedCalculatedField); + + await().alias("update CF argument -> perform calculation with new argument").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("104.0"); + }); + + testRestClient.deleteCalculatedFieldIfExists(savedCalculatedField.getId()); + } + + @Test + public void testChangeConfigOutput() { + // login tenant admin + testRestClient.getAndSetUserToken(tenantAdminId); + + CalculatedField savedCalculatedField = createSimpleCalculatedField(); + + Output savedOutput = savedCalculatedField.getConfiguration().getOutput(); + savedOutput.setType(OutputType.ATTRIBUTES); + savedOutput.setScope(AttributeScope.SERVER_SCOPE); + savedOutput.setName("temperatureF"); + testRestClient.postCalculatedField(savedCalculatedField); + + await().alias("update CF output -> perform calculation with updated output").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode temperatureF = testRestClient.getAttributes(device.getId(), AttributeScope.SERVER_SCOPE, "temperatureF"); + assertThat(temperatureF).isNotNull(); + assertThat(temperatureF.get(0).get("value").asText()).isEqualTo("77.0"); + }); + + testRestClient.deleteCalculatedFieldIfExists(savedCalculatedField.getId()); + } + + @Test + public void testChangeConfigExpression() { + // login tenant admin + testRestClient.getAndSetUserToken(tenantAdminId); + + CalculatedField savedCalculatedField = createSimpleCalculatedField(); + + savedCalculatedField.setName("F to C"); + savedCalculatedField.getConfiguration().setExpression("(T - 32) / 1.8"); + testRestClient.postCalculatedField(savedCalculatedField); + + await().alias("update CF expression -> perform calculation with new expression").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("-3.89"); + }); + + testRestClient.deleteCalculatedFieldIfExists(savedCalculatedField.getId()); + } + + @Test + public void testTelemetryUpdated() { + // login tenant admin + testRestClient.getAndSetUserToken(tenantAdminId); + + CalculatedField savedCalculatedField = createSimpleCalculatedField(); + + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":30}")); + + await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("86.0"); + }); + + testRestClient.deleteCalculatedFieldIfExists(savedCalculatedField.getId()); + } + + @Test + public void testEntityIdIsProfile() { + // login tenant admin + testRestClient.getAndSetUserToken(tenantAdminId); + + CalculatedField savedCalculatedField = createSimpleCalculatedField(deviceProfileId); + + await().alias("create CF -> perform initial calculation for device by profile").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0"); + }); + + testRestClient.deleteCalculatedFieldIfExists(savedCalculatedField.getId()); + } + + @Test + public void testEntityAddedAndDeleted() { + // login tenant admin + testRestClient.getAndSetUserToken(tenantAdminId); + + CalculatedField savedCalculatedField = createSimpleCalculatedField(deviceProfileId); + + String newDeviceToken = "mmmXRIVRsq9lbnTP2XBE"; + Device newDevice = testRestClient.postDevice(newDeviceToken, createDevice("Device 2", deviceProfileId)); + + await().alias("create device by profile -> perform initial calculation for new device by profile").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + // used default value since telemetry is not present + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(newDevice.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("53.6"); + }); + + DeviceProfile newDeviceProfile = testRestClient.postDeviceProfile(defaultDeviceProfile("Test Profile")); + newDevice.setDeviceProfileId(newDeviceProfile.getId()); + testRestClient.postDevice(newDeviceToken, newDevice); + + testRestClient.postTelemetry(newDeviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}")); + + await().alias("update telemetry -> no updates").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(newDevice.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("53.6"); + }); + + testRestClient.deleteCalculatedFieldIfExists(savedCalculatedField.getId()); + } + + private CalculatedField createSimpleCalculatedField() { + return createSimpleCalculatedField(device.getId()); + } + + private CalculatedField createSimpleCalculatedField(EntityId entityId) { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(entityId); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("C to F" + RandomStringUtils.randomAlphabetic(5)); + calculatedField.setDebugSettings(DebugSettings.all()); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); + argument.setRefEntityKey(refEntityKey); + argument.setDefaultValue("12"); // not used because real telemetry value in db is present + config.setArguments(Map.of("T", argument)); + + config.setExpression("(T * 9/5) + 32"); + + Output output = new Output(); + output.setName("fahrenheitTemp"); + output.setType(OutputType.TIME_SERIES); + output.setDecimalsByDefault(2); + config.setOutput(output); + + calculatedField.setConfiguration(config); + + return testRestClient.postCalculatedField(calculatedField); + } + + private CalculatedField createScriptCalculatedField() { + return createScriptCalculatedField(device.getId()); + } + + private CalculatedField createScriptCalculatedField(EntityId entityId) { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(entityId); + calculatedField.setType(CalculatedFieldType.SCRIPT); + calculatedField.setName("Air density" + RandomStringUtils.randomAlphabetic(5)); + calculatedField.setDebugSettings(DebugSettings.all()); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument1 = new Argument(); + argument1.setRefEntityId(asset.getId()); + ReferencedEntityKey refEntityKey1 = new ReferencedEntityKey("altitude", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE); + argument1.setRefEntityKey(refEntityKey1); + config.setArguments(Map.of("altitude", argument1)); + Argument argument2 = new Argument(); + ReferencedEntityKey refEntityKey2 = new ReferencedEntityKey("temperatureInF", ArgumentType.TS_ROLLING, null); + argument2.setRefEntityKey(refEntityKey2); + config.setArguments(Map.of("temperature", argument2)); + + config.setExpression("return {\"airDensity\": 5};"); + + Output output = new Output(); + output.setType(OutputType.TIME_SERIES); + config.setOutput(output); + + calculatedField.setConfiguration(config); + + return testRestClient.postCalculatedField(calculatedField); + } + + private Device createDevice(String name, DeviceProfileId deviceProfileId) { + Device device = new Device(); + device.setName(name); + device.setType("default"); + device.setDeviceProfileId(deviceProfileId); + DeviceData deviceData = new DeviceData(); + deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration()); + deviceData.setConfiguration(new DefaultDeviceConfiguration()); + device.setDeviceData(deviceData); + return device; + } + + private Asset createAsset(String name, AssetProfileId assetProfileId) { + Asset asset = new Asset(); + asset.setName(name); + asset.setAssetProfileId(assetProfileId); + return asset; + } + +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java index b96ea386d2..4e28340f9d 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java @@ -77,7 +77,7 @@ public class HttpClientTest extends AbstractContainerTest { assertThat(accessToken).isNotNull(); JsonNode sharedAttribute = mapper.readTree(createPayload().toString()); - testRestClient.postTelemetryAttribute(DEVICE, device.getId(), SHARED_SCOPE, sharedAttribute); + testRestClient.postTelemetryAttribute(device.getId(), SHARED_SCOPE, sharedAttribute); JsonNode clientAttribute = mapper.readTree(createPayload().toString()); testRestClient.postAttribute(accessToken, clientAttribute); diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java index 00e496327c..ebdfb4e3c9 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java @@ -212,7 +212,7 @@ public class MqttClientTest extends AbstractContainerTest { String sharedAttributeValue = StringUtils.randomAlphanumeric(8); sharedAttributes.addProperty("sharedAttr", sharedAttributeValue); JsonNode sharedAttribute = mapper.readTree(sharedAttributes.toString()); - testRestClient.postTelemetryAttribute(DEVICE, device.getId(), SHARED_SCOPE, sharedAttribute); + testRestClient.postTelemetryAttribute(device.getId(), SHARED_SCOPE, sharedAttribute); // Subscribe to attributes response mqttClient.on("v1/devices/me/attributes/response/+", listener, MqttQoS.AT_LEAST_ONCE).get(); @@ -255,7 +255,7 @@ public class MqttClientTest extends AbstractContainerTest { sharedAttributes.addProperty(sharedAttributeName, sharedAttributeValue); JsonNode sharedAttribute = mapper.readTree(sharedAttributes.toString()); - testRestClient.postTelemetryAttribute(DataConstants.DEVICE, device.getId(), SHARED_SCOPE, sharedAttribute); + testRestClient.postTelemetryAttribute(device.getId(), SHARED_SCOPE, sharedAttribute); MqttEvent event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); assertThat(mapper.readValue(Objects.requireNonNull(event).getMessage(), JsonNode.class).get(sharedAttributeName).asText()) @@ -265,7 +265,7 @@ public class MqttClientTest extends AbstractContainerTest { JsonObject updatedSharedAttributes = new JsonObject(); String updatedSharedAttributeValue = StringUtils.randomAlphanumeric(8); updatedSharedAttributes.addProperty(sharedAttributeName, updatedSharedAttributeValue); - testRestClient.postTelemetryAttribute(DEVICE, device.getId(), SHARED_SCOPE, mapper.readTree(updatedSharedAttributes.toString())); + testRestClient.postTelemetryAttribute(device.getId(), SHARED_SCOPE, mapper.readTree(updatedSharedAttributes.toString())); event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); assertThat(mapper.readValue(Objects.requireNonNull(event).getMessage(), JsonNode.class).get(sharedAttributeName).asText()) diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java index b2335ae2f7..cc587fbbd5 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java @@ -184,7 +184,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest { mqttClient.on("v1/gateway/attributes/response", listener, MqttQoS.AT_LEAST_ONCE).get(); - testRestClient.postTelemetryAttribute(DataConstants.DEVICE, createdDevice.getId(), SHARED_SCOPE, mapper.readTree(sharedAttributes.toString())); + testRestClient.postTelemetryAttribute(createdDevice.getId(), SHARED_SCOPE, mapper.readTree(sharedAttributes.toString())); var event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); JsonObject requestData = new JsonObject(); @@ -266,7 +266,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest { // Subscribe for attribute update event mqttClient.on("v1/gateway/attributes", listener, MqttQoS.AT_LEAST_ONCE).get(); - testRestClient.postTelemetryAttribute(DEVICE, createdDevice.getId(), SHARED_SCOPE, mapper.readTree(sharedAttributes.toString())); + testRestClient.postTelemetryAttribute(createdDevice.getId(), SHARED_SCOPE, mapper.readTree(sharedAttributes.toString())); MqttEvent sharedAttributeEvent = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); // Catch attribute update event @@ -299,7 +299,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest { gatewaySharedAttributeValue.addProperty("device", createdDevice.getName()); gatewaySharedAttributeValue.add("data", sharedAttributes); - testRestClient.postTelemetryAttribute(DEVICE, createdDevice.getId(), SHARED_SCOPE, mapper.readTree(sharedAttributes.toString())); + testRestClient.postTelemetryAttribute(createdDevice.getId(), SHARED_SCOPE, mapper.readTree(sharedAttributes.toString())); MqttEvent event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); assertThat(mapper.readValue(Objects.requireNonNull(event).getMessage(), JsonNode.class).get("data").get(sharedAttributeName).asText()) @@ -314,7 +314,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest { gatewayUpdatedSharedAttributeValue.addProperty("device", createdDevice.getName()); gatewayUpdatedSharedAttributeValue.add("data", updatedSharedAttributes); - testRestClient.postTelemetryAttribute(DEVICE, createdDevice.getId(), SHARED_SCOPE, mapper.readTree(updatedSharedAttributes.toString())); + testRestClient.postTelemetryAttribute(createdDevice.getId(), SHARED_SCOPE, mapper.readTree(updatedSharedAttributes.toString())); event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); assertThat(mapper.readValue(Objects.requireNonNull(event).getMessage(), JsonNode.class).get("data").get(sharedAttributeName).asText()) .isEqualTo(updatedSharedAttributeValue); diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/edqs/EdqsEntityDataQueryTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/edqs/EdqsEntityDataQueryTest.java new file mode 100644 index 0000000000..53d8a72e7f --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/edqs/EdqsEntityDataQueryTest.java @@ -0,0 +1,214 @@ +/** + * 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. + */ +package org.thingsboard.server.msa.edqs; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.DeviceTypeFilter; +import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityTypeFilter; +import org.thingsboard.server.common.data.query.TsValue; +import org.thingsboard.server.msa.AbstractContainerTest; +import org.thingsboard.server.msa.DisableUIListeners; +import org.thingsboard.server.msa.ui.utils.EntityPrototypes; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.thingsboard.server.msa.ui.utils.EntityPrototypes.defaultCustomer; +import static org.thingsboard.server.msa.ui.utils.EntityPrototypes.defaultCustomerAdmin; +import static org.thingsboard.server.msa.ui.utils.EntityPrototypes.defaultDeviceProfile; +import static org.thingsboard.server.msa.ui.utils.EntityPrototypes.defaultTenantAdmin; + +@DisableUIListeners +public class EdqsEntityDataQueryTest extends AbstractContainerTest { + + private TenantId tenantId; + private CustomerId customerId; + private TenantId tenantId2; + private CustomerId customerId2; + private UserId tenantAdminId; + private UserId customerUserId; + private UserId tenant2AdminId; + private UserId customer2UserId; + private final List tenantDevices = new ArrayList<>(); + private final List tenant2Devices = new ArrayList<>(); + private final String deviceProfile = "LoRa-" + RandomStringUtils.randomAlphabetic(10); + + @BeforeClass + public void beforeClass() throws Exception { + testRestClient.login("sysadmin@thingsboard.org", "sysadmin"); + await().atMost(60, TimeUnit.SECONDS).until(() -> testRestClient.isEdqsApiEnabled()); + + tenantId = testRestClient.postTenant(EntityPrototypes.defaultTenantPrototype("Tenant")).getId(); + tenantAdminId = testRestClient.createUserAndLogin(defaultTenantAdmin(tenantId, "tenantAdmin@thingsboard.org"), "tenant"); + testRestClient.postDeviceProfile(defaultDeviceProfile(deviceProfile)); + createDevices(deviceProfile, tenantDevices, 97); + customerId = testRestClient.postCustomer(defaultCustomer(tenantId, "Customer")).getId(); + customerUserId = testRestClient.postUser(defaultCustomerAdmin(tenantId, customerId, "customerUser@thingsboard.org")).getId(); + assignDevicesToCustomer(customerId, tenantDevices, 12); + + testRestClient.login("sysadmin@thingsboard.org", "sysadmin"); + tenantId2 = testRestClient.postTenant(EntityPrototypes.defaultTenantPrototype("Tenant")).getId(); + tenant2AdminId = testRestClient.createUserAndLogin(defaultTenantAdmin(tenantId2, "tenant2Admin@thingsboard.org"), "tenant"); + testRestClient.postDeviceProfile(defaultDeviceProfile(deviceProfile)); + createDevices(deviceProfile, tenant2Devices, 97); + customerId2 = testRestClient.postCustomer(defaultCustomer(tenantId2, "Customer")).getId(); + customer2UserId = testRestClient.postUser(defaultCustomerAdmin(tenantId2, customerId2, "customer2User@thingsboard.org")).getId(); + assignDevicesToCustomer(customerId2, tenant2Devices, 12); + } + + @BeforeMethod + public void beforeMethod() { + testRestClient.login("sysadmin@thingsboard.org", "sysadmin"); + } + + @AfterClass + public void afterClass() { + testRestClient.resetToken(); + testRestClient.login("sysadmin@thingsboard.org", "sysadmin"); + testRestClient.deleteTenant(tenantId); + testRestClient.deleteTenant(tenantId2); + } + + @Test + public void testSysAdminCountEntitiesByQuery() { + EntityTypeFilter allDeviceFilter = new EntityTypeFilter(); + allDeviceFilter.setEntityType(EntityType.DEVICE); + EntityCountQuery query = new EntityCountQuery(allDeviceFilter); + await("Waiting for total device count") + .atMost(30, TimeUnit.SECONDS) + .until(() -> testRestClient.postCountDataQuery(query).compareTo(97L * 2) >= 0); + + testRestClient.getAndSetUserToken(tenantAdminId); + await("Waiting for total device count") + .atMost(30, TimeUnit.SECONDS) + .until(() -> testRestClient.postCountDataQuery(query).equals(97L)); + + testRestClient.resetToken(); + testRestClient.login("sysadmin@thingsboard.org", "sysadmin"); + testRestClient.getAndSetUserToken(tenant2AdminId); + await("Waiting for total device count") + .atMost(30, TimeUnit.SECONDS) + .until(() -> testRestClient.postCountDataQuery(query).equals(97L)); + } + + @Test + public void testRetrieveTenantDevicesByDeviceTypeFilter() { + // login tenant admin + testRestClient.getAndSetUserToken(tenantAdminId); + checkUserDevices(tenantDevices); + + // login customer user + testRestClient.getAndSetUserToken(customerUserId); + checkUserDevices(tenantDevices.subList(0, 12)); + + // login other tenant admin + testRestClient.resetToken(); + testRestClient.login("sysadmin@thingsboard.org", "sysadmin"); + testRestClient.getAndSetUserToken(tenant2AdminId); + checkUserDevices(tenant2Devices); + } + + private void checkUserDevices(List devices) { + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of(deviceProfile)); + filter.setDeviceNameFilter(""); + + EntityDataSortOrder sortOrder = new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime"), EntityDataSortOrder.Direction.ASC); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder); + List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + List latestFields = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "temperature")); + EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestFields, null); + + EntityTypeFilter allDeviceFilter = new EntityTypeFilter(); + allDeviceFilter.setEntityType(EntityType.DEVICE); + EntityCountQuery countQuery = new EntityCountQuery(allDeviceFilter); + await("Waiting for total device count") + .atMost(30, TimeUnit.SECONDS) + .until(() -> testRestClient.postCountDataQuery(countQuery).intValue() == devices.size()); + + PageData result = testRestClient.postEntityDataQuery(query); + assertThat(result.getTotalElements()).isEqualTo(devices.size()); + List retrievedDevices = result.getData(); + + assertThat(retrievedDevices).hasSize(10); + List retrievedDeviceNames = retrievedDevices.stream().map(entityData -> entityData.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()).toList(); + assertThat(retrievedDeviceNames).containsExactlyInAnyOrderElementsOf(devices.stream().map(Device::getName).toList().subList(0, 10)); + + //check temperature + for (int i = 0; i < 10; i++) { + Map> latest = retrievedDevices.get(i).getLatest(); + String name = latest.get(EntityKeyType.ENTITY_FIELD).get("name").getValue(); + assertThat(latest.get(EntityKeyType.TIME_SERIES).get("temperature").getValue()).isEqualTo(name.substring(name.length() - 1)); + } + } + + private String createDevices(String deviceType, List tenantDevices, int deviceCount) throws InterruptedException { + String prefix = StringUtils.randomAlphabetic(5); + for (int i = 0; i < deviceCount; i++) { + Device device = new Device(); + device.setName(prefix + "Device" + i); + device.setType(deviceType); + device.setLabel("testLabel" + (int) (Math.random() * 1000)); + //TO make sure devices have different created time + Thread.sleep(1); + String token = RandomStringUtils.randomAlphabetic(10); + Device saved = testRestClient.postDevice(token, device); + tenantDevices.add(saved); + + // save timeseries data + testRestClient.postTelemetry(token, createDeviceTelemetry(i)); + } + return deviceType; + } + + private void assignDevicesToCustomer(CustomerId customerId, List devices, int deviceCount) { + for (int i = 0; i < deviceCount; i++) { + Device device = devices.get(i); + testRestClient.assignDeviceToCustomer(customerId, device.getId()); + } + } + + protected ObjectNode createDeviceTelemetry(int temperature) { + ObjectNode objectNode = mapper.createObjectNode(); + objectNode.put("temperature", temperature); + return objectNode; + } + +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/utils/EntityPrototypes.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/utils/EntityPrototypes.java index ce5fee1f6f..91620271b7 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/utils/EntityPrototypes.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/utils/EntityPrototypes.java @@ -25,6 +25,7 @@ import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmSeverity; @@ -37,12 +38,26 @@ import org.thingsboard.server.common.data.device.profile.DisabledDeviceProfilePr import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.security.Authority; public class EntityPrototypes { + public static Tenant defaultTenantPrototype(String tenantName) { + Tenant tenant = new Tenant(); + tenant.setTitle(tenantName); + return tenant; + } + + public static Customer defaultCustomer(TenantId tenantId, String title) { + Customer customer = new Customer(); + customer.setTenantId(tenantId); + customer.setTitle(title); + return customer; + } + public static Customer defaultCustomerPrototype(String entityName) { Customer customer = new Customer(); customer.setTitle(entityName); @@ -169,6 +184,23 @@ public class EntityPrototypes { return user; } + public static User defaultTenantAdmin(TenantId tenantId, String email) { + User user = new User(); + user.setTenantId(tenantId); + user.setEmail(email); + user.setAuthority(Authority.TENANT_ADMIN); + return user; + } + + public static User defaultCustomerAdmin(TenantId tenantId, CustomerId customerId, String email) { + User user = new User(); + user.setTenantId(tenantId); + user.setCustomerId(customerId); + user.setEmail(email); + user.setAuthority(Authority.CUSTOMER_USER); + return user; + } + public static User defaultUser(String email, CustomerId customerId, String name) { User user = new User(); user.setEmail(email); diff --git a/msa/black-box-tests/src/test/resources/connectivity.xml b/msa/black-box-tests/src/test/resources/connectivity.xml index 2bde3f0a3f..425fbd67eb 100644 --- a/msa/black-box-tests/src/test/resources/connectivity.xml +++ b/msa/black-box-tests/src/test/resources/connectivity.xml @@ -22,6 +22,7 @@ + \ No newline at end of file diff --git a/msa/edqs/docker/Dockerfile b/msa/edqs/docker/Dockerfile new file mode 100644 index 0000000000..e9099c09c5 --- /dev/null +++ b/msa/edqs/docker/Dockerfile @@ -0,0 +1,31 @@ +# +# 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. +# + +FROM thingsboard/openjdk17:bookworm-slim + +COPY start-tb-edqs.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-tb-edqs.sh /usr/bin && \ + (yes | dpkg -i /tmp/${pkg.name}.deb) && \ + rm /tmp/${pkg.name}.deb && \ + (systemctl --no-reload disable --now ${pkg.name}.service > /dev/null 2>&1 || :) && \ + chown -R ${pkg.user}:${pkg.user} /tmp && \ + chmod 555 ${pkg.installFolder}/bin/${pkg.name}.jar + +USER ${pkg.user} + +CMD ["start-tb-edqs.sh"] \ No newline at end of file diff --git a/msa/edqs/docker/start-tb-edqs.sh b/msa/edqs/docker/start-tb-edqs.sh new file mode 100755 index 0000000000..deb0f70eff --- /dev/null +++ b/msa/edqs/docker/start-tb-edqs.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# +# 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. +# + +CONF_FOLDER=${pkg.installFolder}/conf +jarfile=${pkg.installFolder}/bin/${pkg.name}.jar +configfile=${pkg.name}.conf + +source "${CONF_FOLDER}/${configfile}" + +echo "Starting '${project.name}' ..." + +cd ${pkg.installFolder}/bin + +exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.edqs.ThingsboardEdqsApplication \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dlogging.config=$CONF_FOLDER/logback.xml \ + org.springframework.boot.loader.launch.PropertiesLauncher diff --git a/msa/edqs/pom.xml b/msa/edqs/pom.xml new file mode 100644 index 0000000000..f22cb0187c --- /dev/null +++ b/msa/edqs/pom.xml @@ -0,0 +1,190 @@ + + + 4.0.0 + + org.thingsboard + 4.0.0-SNAPSHOT + msa + + org.thingsboard.msa + edqs + pom + + ThingsBoard Entity Data Query Microservice + https://thingsboard.io + ThingsBoard Entity Data Query Microservice + + + UTF-8 + ${basedir}/../.. + edqs + tb-edqs + /var/log/${pkg.name} + /usr/share/${pkg.name} + pre-integration-test + + + + + org.thingsboard + edqs + ${project.version} + deb + deb + provided + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-edqs + package + + copy + + + + + org.thingsboard + edqs + deb + deb + ${pkg.name}.deb + ${project.build.directory} + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-docker-config + process-resources + + copy-resources + + + ${project.build.directory} + + + docker + true + + + + + + + + com.spotify + dockerfile-maven-plugin + + + build-docker-image + pre-integration-test + + build + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + true + false + ${project.build.directory} + + + + tag-docker-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + ${project.version} + + + + + + + + + push-docker-image + + + push-docker-image + + + + + + com.spotify + dockerfile-maven-plugin + + + push-latest-docker-image + pre-integration-test + + push + + + latest + ${docker.repo}/${docker.name} + + + + push-version-docker-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${docker.name} + + + + + + + + + + + jenkins + Jenkins Repository + https://repo.jenkins-ci.org/releases + + false + + + + diff --git a/msa/pom.xml b/msa/pom.xml index 5ae0e903c1..98e820daaf 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -48,6 +48,7 @@ transport js-executor monitoring + edqs diff --git a/msa/tb/docker-cassandra/Dockerfile b/msa/tb/docker-cassandra/Dockerfile index 9c49ffe2f9..20a0d8cf7a 100644 --- a/msa/tb/docker-cassandra/Dockerfile +++ b/msa/tb/docker-cassandra/Dockerfile @@ -29,7 +29,6 @@ ENV CASSANDRA_DATA=/data/cassandra ENV SPRING_DRIVER_CLASS_NAME=org.postgresql.Driver ENV SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/thingsboard ENV SPRING_DATASOURCE_USERNAME=${pkg.user} -ENV SPRING_DATASOURCE_PASSWORD=postgres ENV CASSANDRA_HOST=localhost ENV CASSANDRA_PORT=9042 diff --git a/msa/tb/docker-postgres/Dockerfile b/msa/tb/docker-postgres/Dockerfile index f9e36ed61e..7c66d626c7 100644 --- a/msa/tb/docker-postgres/Dockerfile +++ b/msa/tb/docker-postgres/Dockerfile @@ -29,7 +29,6 @@ ENV PATH=$PATH:/usr/lib/postgresql/$PG_MAJOR/bin ENV SPRING_DRIVER_CLASS_NAME=org.postgresql.Driver ENV SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/thingsboard ENV SPRING_DATASOURCE_USERNAME=${pkg.user} -ENV SPRING_DATASOURCE_PASSWORD=postgres ENV PGLOG=/var/log/postgres diff --git a/msa/vc-executor/src/main/resources/tb-vc-executor.yml b/msa/vc-executor/src/main/resources/tb-vc-executor.yml index 1d4d1592cf..bb7f607ca0 100644 --- a/msa/vc-executor/src/main/resources/tb-vc-executor.yml +++ b/msa/vc-executor/src/main/resources/tb-vc-executor.yml @@ -151,6 +151,8 @@ queue: core: # Default topic name topic: "${TB_QUEUE_CORE_TOPIC:tb_core}" + # For high-priority notifications that require minimum latency and processing time + notifications_topic: "${TB_QUEUE_CORE_NOTIFICATIONS_TOPIC:tb_core.notifications}" # Interval in milliseconds to poll messages by Core microservices poll-interval: "${TB_QUEUE_CORE_POLL_INTERVAL_MS:25}" # Amount of partitions used by Core microservices @@ -205,7 +207,9 @@ usage: # Enable/Disable collection of statistics about API usage on a customer level enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Interval of reporting the statistics. By default, the summarized statistics are sent every 10 seconds - interval: "${USAGE_STATS_REPORT_INTERVAL:10}" + interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Amount of statistic messages in pack + pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" # Metrics parameters metrics: diff --git a/pom.xml b/pom.xml index b851b8fc10..c0dc7384f7 100755 --- a/pom.xml +++ b/pom.xml @@ -74,6 +74,7 @@ 1.7.0 4.4.0 2.2.14 + 0.6.12 3.12.1 2.0.0-M15 2.10.1 @@ -83,7 +84,7 @@ 3.9.2 3.25.5 1.63.0 - 1.2.5 + 1.2.6 1.18.32 1.2.5 1.2.5 @@ -165,6 +166,8 @@ 1.6.1 2.19.0 9.2.0 + 1.1.10.5 + 9.10.0 @@ -172,6 +175,7 @@ common rule-engine dao + edqs transport ui-ngx tools @@ -848,6 +852,8 @@ .run/** **/NetworkReceive.java **/lwm2m-registry/** + **/test/resources/lwm2m/** + **/resources/lwm2m/models/** src/main/data/resources/** @@ -1031,6 +1037,11 @@ coap-server ${project.version} + + org.thingsboard.common + edqs + ${project.version} + org.thingsboard.common.script script-api @@ -2279,6 +2290,16 @@ metadata-extractor ${drewnoakes-metadata-extractor.version} + + org.xerial.snappy + snappy-java + ${snappy.version} + + + org.rocksdb + rocksdbjni + ${rocksdbjni.version} + diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesDeleteRequest.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesDeleteRequest.java index f1142443ea..374fcc45f6 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesDeleteRequest.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesDeleteRequest.java @@ -21,22 +21,31 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.ToString; +import org.thingsboard.common.util.NoOpFutureCallback; import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; import java.util.List; +import java.util.UUID; + +import static java.util.Objects.requireNonNullElse; @Getter @ToString @AllArgsConstructor(access = AccessLevel.PRIVATE) -public class AttributesDeleteRequest { +public class AttributesDeleteRequest implements CalculatedFieldSystemAwareRequest { private final TenantId tenantId; private final EntityId entityId; private final AttributeScope scope; private final List keys; private final boolean notifyDevice; + private final List previousCalculatedFieldIds; + private final UUID tbMsgId; + private final TbMsgType tbMsgType; private final FutureCallback callback; public static Builder builder() { @@ -50,6 +59,9 @@ public class AttributesDeleteRequest { private AttributeScope scope; private List keys; private boolean notifyDevice; + private List previousCalculatedFieldIds; + private UUID tbMsgId; + private TbMsgType tbMsgType; private FutureCallback callback; Builder() {} @@ -89,6 +101,21 @@ public class AttributesDeleteRequest { return this; } + public Builder previousCalculatedFieldIds(List previousCalculatedFieldIds) { + this.previousCalculatedFieldIds = previousCalculatedFieldIds; + return this; + } + + public Builder tbMsgId(UUID tbMsgId) { + this.tbMsgId = tbMsgId; + return this; + } + + public Builder tbMsgType(TbMsgType tbMsgType) { + this.tbMsgType = tbMsgType; + return this; + } + public Builder callback(FutureCallback callback) { this.callback = callback; return this; @@ -109,7 +136,9 @@ public class AttributesDeleteRequest { } public AttributesDeleteRequest build() { - return new AttributesDeleteRequest(tenantId, entityId, scope, keys, notifyDevice, callback); + return new AttributesDeleteRequest( + tenantId, entityId, scope, keys, notifyDevice, previousCalculatedFieldIds, tbMsgId, tbMsgType, requireNonNullElse(callback, NoOpFutureCallback.instance()) + ); } } diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesSaveRequest.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesSaveRequest.java index c7d09d1525..c3095836bd 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesSaveRequest.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesSaveRequest.java @@ -21,27 +21,45 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.ToString; +import org.thingsboard.common.util.NoOpFutureCallback; import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.msg.TbMsgType; import java.util.List; +import java.util.UUID; + +import static java.util.Objects.requireNonNullElse; @Getter @ToString @AllArgsConstructor(access = AccessLevel.PRIVATE) -public class AttributesSaveRequest { +public class AttributesSaveRequest implements CalculatedFieldSystemAwareRequest { private final TenantId tenantId; private final EntityId entityId; private final AttributeScope scope; private final List entries; private final boolean notifyDevice; + private final Strategy strategy; + private final List previousCalculatedFieldIds; + private final UUID tbMsgId; + private final TbMsgType tbMsgType; private final FutureCallback callback; + public record Strategy(boolean saveAttributes, boolean sendWsUpdate, boolean processCalculatedFields) { + + public static final Strategy PROCESS_ALL = new Strategy(true, true, true); + public static final Strategy WS_ONLY = new Strategy(false, true, false); + public static final Strategy SKIP_ALL = new Strategy(false, false, false); + + } + public static Builder builder() { return new Builder(); } @@ -53,6 +71,10 @@ public class AttributesSaveRequest { private AttributeScope scope; private List entries; private boolean notifyDevice = true; + private Strategy strategy; + private List previousCalculatedFieldIds; + private UUID tbMsgId; + private TbMsgType tbMsgType; private FutureCallback callback; Builder() {} @@ -100,6 +122,26 @@ public class AttributesSaveRequest { return this; } + public Builder strategy(Strategy strategy) { + this.strategy = strategy; + return this; + } + + public Builder previousCalculatedFieldIds(List previousCalculatedFieldIds) { + this.previousCalculatedFieldIds = previousCalculatedFieldIds; + return this; + } + + public Builder tbMsgId(UUID tbMsgId) { + this.tbMsgId = tbMsgId; + return this; + } + + public Builder tbMsgType(TbMsgType tbMsgType) { + this.tbMsgType = tbMsgType; + return this; + } + public Builder callback(FutureCallback callback) { this.callback = callback; return this; @@ -120,7 +162,10 @@ public class AttributesSaveRequest { } public AttributesSaveRequest build() { - return new AttributesSaveRequest(tenantId, entityId, scope, entries, notifyDevice, callback); + return new AttributesSaveRequest( + tenantId, entityId, scope, entries, notifyDevice, requireNonNullElse(strategy, Strategy.PROCESS_ALL), + previousCalculatedFieldIds, tbMsgId, tbMsgType, requireNonNullElse(callback, NoOpFutureCallback.instance()) + ); } } diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/CalculatedFieldSystemAwareRequest.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/CalculatedFieldSystemAwareRequest.java new file mode 100644 index 0000000000..fa4c414172 --- /dev/null +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/CalculatedFieldSystemAwareRequest.java @@ -0,0 +1,32 @@ +/** + * 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. + */ +package org.thingsboard.rule.engine.api; + +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.msg.TbMsgType; + +import java.util.List; +import java.util.UUID; + +public interface CalculatedFieldSystemAwareRequest { + + List getPreviousCalculatedFieldIds(); + + UUID getTbMsgId(); + + TbMsgType getTbMsgType(); + +} diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineDeviceStateManager.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/DeviceStateManager.java similarity index 88% rename from rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineDeviceStateManager.java rename to rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/DeviceStateManager.java index fb3e282c9a..887f7ecaa2 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineDeviceStateManager.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/DeviceStateManager.java @@ -19,7 +19,7 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.queue.TbCallback; -public interface RuleEngineDeviceStateManager { +public interface DeviceStateManager { void onDeviceConnect(TenantId tenantId, DeviceId deviceId, long connectTime, TbCallback callback); @@ -29,4 +29,6 @@ public interface RuleEngineDeviceStateManager { void onDeviceInactivity(TenantId tenantId, DeviceId deviceId, long inactivityTime, TbCallback callback); + void onDeviceInactivityTimeoutUpdate(TenantId tenantId, DeviceId deviceId, long inactivityTimeout, TbCallback callback); + } diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineCalculatedFieldQueueService.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineCalculatedFieldQueueService.java new file mode 100644 index 0000000000..6ab40b79c2 --- /dev/null +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineCalculatedFieldQueueService.java @@ -0,0 +1,26 @@ +/** + * 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. + */ +package org.thingsboard.rule.engine.api; + +import com.google.common.util.concurrent.FutureCallback; + +public interface RuleEngineCalculatedFieldQueueService { + + void pushRequestToQueue(TimeseriesSaveRequest request, FutureCallback callback); + + void pushRequestToQueue(AttributesSaveRequest request, FutureCallback callback); + +} diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java index 46b63e0595..b66c9e13d5 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java @@ -50,6 +50,7 @@ import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.cassandra.CassandraCluster; +import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.DeviceCredentialsService; @@ -279,7 +280,7 @@ public interface TbContext { DeviceCredentialsService getDeviceCredentialsService(); - RuleEngineDeviceStateManager getDeviceStateManager(); + DeviceStateManager getDeviceStateManager(); String getDeviceStateNodeRateLimitConfig(); @@ -357,6 +358,10 @@ public interface TbContext { SlackService getSlackService(); + CalculatedFieldService getCalculatedFieldService(); + + RuleEngineCalculatedFieldQueueService getCalculatedFieldQueueService(); + boolean isExternalNodeForceAck(); /** @@ -370,12 +375,6 @@ public interface TbContext { ScriptEngine createScriptEngine(ScriptLanguage scriptLang, String script, String... argNames); - void logJsEvalRequest(); - - void logJsEvalResponse(); - - void logJsEvalFailure(); - String getServiceId(); EventLoopGroup getSharedEventLoop(); diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TimeseriesDeleteRequest.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TimeseriesDeleteRequest.java index 01cad78b98..c3f6b5c74c 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TimeseriesDeleteRequest.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TimeseriesDeleteRequest.java @@ -20,21 +20,27 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.ToString; +import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; +import org.thingsboard.server.common.data.msg.TbMsgType; import java.util.List; +import java.util.UUID; @Getter @ToString @AllArgsConstructor(access = AccessLevel.PRIVATE) -public class TimeseriesDeleteRequest { +public class TimeseriesDeleteRequest implements CalculatedFieldSystemAwareRequest { private final TenantId tenantId; private final EntityId entityId; private final List keys; private final List deleteHistoryQueries; + private final List previousCalculatedFieldIds; + private final UUID tbMsgId; + private final TbMsgType tbMsgType; private final FutureCallback> callback; public static Builder builder() { @@ -47,6 +53,9 @@ public class TimeseriesDeleteRequest { private EntityId entityId; private List keys; private List deleteHistoryQueries; + private List previousCalculatedFieldIds; + private UUID tbMsgId; + private TbMsgType tbMsgType; private FutureCallback> callback; Builder() {} @@ -71,13 +80,28 @@ public class TimeseriesDeleteRequest { return this; } + public Builder previousCalculatedFieldIds(List previousCalculatedFieldIds) { + this.previousCalculatedFieldIds = previousCalculatedFieldIds; + return this; + } + + public Builder tbMsgId(UUID tbMsgId) { + this.tbMsgId = tbMsgId; + return this; + } + + public Builder tbMsgType(TbMsgType tbMsgType) { + this.tbMsgType = tbMsgType; + return this; + } + public Builder callback(FutureCallback> callback) { this.callback = callback; return this; } public TimeseriesDeleteRequest build() { - return new TimeseriesDeleteRequest(tenantId, entityId, keys, deleteHistoryQueries, callback); + return new TimeseriesDeleteRequest(tenantId, entityId, keys, deleteHistoryQueries, previousCalculatedFieldIds, tbMsgId, tbMsgType, callback); } } diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TimeseriesSaveRequest.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TimeseriesSaveRequest.java index 103b354be9..c402a0c984 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TimeseriesSaveRequest.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TimeseriesSaveRequest.java @@ -20,18 +20,24 @@ import com.google.common.util.concurrent.SettableFuture; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; +import org.thingsboard.common.util.NoOpFutureCallback; +import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.msg.TbMsgType; import java.util.List; +import java.util.UUID; + +import static java.util.Objects.requireNonNullElse; @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) -public class TimeseriesSaveRequest { +public class TimeseriesSaveRequest implements CalculatedFieldSystemAwareRequest { private final TenantId tenantId; private final CustomerId customerId; @@ -39,14 +45,17 @@ public class TimeseriesSaveRequest { private final List entries; private final long ttl; private final Strategy strategy; + private final List previousCalculatedFieldIds; + private final UUID tbMsgId; + private final TbMsgType tbMsgType; private final FutureCallback callback; - public record Strategy(boolean saveTimeseries, boolean saveLatest, boolean sendWsUpdate) { + public record Strategy(boolean saveTimeseries, boolean saveLatest, boolean sendWsUpdate, boolean processCalculatedFields) { - public static final Strategy SAVE_ALL = new Strategy(true, true, true); - public static final Strategy WS_ONLY = new Strategy(false, false, true); - public static final Strategy LATEST_AND_WS = new Strategy(false, true, true); - public static final Strategy SKIP_ALL = new Strategy(false, false, false); + public static final Strategy PROCESS_ALL = new Strategy(true, true, true, true); + public static final Strategy WS_ONLY = new Strategy(false, false, true, false); + public static final Strategy LATEST_AND_WS = new Strategy(false, true, true, false); + public static final Strategy SKIP_ALL = new Strategy(false, false, false, false); } @@ -61,7 +70,10 @@ public class TimeseriesSaveRequest { private EntityId entityId; private List entries; private long ttl; - private Strategy strategy = Strategy.SAVE_ALL; + private Strategy strategy; + private List previousCalculatedFieldIds; + private UUID tbMsgId; + private TbMsgType tbMsgType; private FutureCallback callback; Builder() {} @@ -104,6 +116,21 @@ public class TimeseriesSaveRequest { return this; } + public Builder previousCalculatedFieldIds(List previousCalculatedFieldIds) { + this.previousCalculatedFieldIds = previousCalculatedFieldIds; + return this; + } + + public Builder tbMsgId(UUID tbMsgId) { + this.tbMsgId = tbMsgId; + return this; + } + + public Builder tbMsgType(TbMsgType tbMsgType) { + this.tbMsgType = tbMsgType; + return this; + } + public Builder callback(FutureCallback callback) { this.callback = callback; return this; @@ -124,7 +151,10 @@ public class TimeseriesSaveRequest { } public TimeseriesSaveRequest build() { - return new TimeseriesSaveRequest(tenantId, customerId, entityId, entries, ttl, strategy, callback); + return new TimeseriesSaveRequest( + tenantId, customerId, entityId, entries, ttl, requireNonNullElse(strategy, Strategy.PROCESS_ALL), + previousCalculatedFieldIds, tbMsgId, tbMsgType, requireNonNullElse(callback, NoOpFutureCallback.instance()) + ); } } diff --git a/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/AttributesDeleteRequestTest.java b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/AttributesDeleteRequestTest.java new file mode 100644 index 0000000000..9b4a825a66 --- /dev/null +++ b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/AttributesDeleteRequestTest.java @@ -0,0 +1,39 @@ +/** + * 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. + */ +package org.thingsboard.rule.engine.api; + +import org.junit.jupiter.api.Test; +import org.thingsboard.common.util.NoOpFutureCallback; + +import static org.assertj.core.api.Assertions.assertThat; + +class AttributesDeleteRequestTest { + + @Test + void testDefaultCallbackIsNoOp() { + var request = AttributesDeleteRequest.builder().build(); + + assertThat(request.getCallback()).isEqualTo(NoOpFutureCallback.instance()); + } + + @Test + void testNullCallbackIsNoOp() { + var request = AttributesDeleteRequest.builder().callback(null).build(); + + assertThat(request.getCallback()).isEqualTo(NoOpFutureCallback.instance()); + } + +} diff --git a/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/AttributesSaveRequestTest.java b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/AttributesSaveRequestTest.java new file mode 100644 index 0000000000..d632edfbf9 --- /dev/null +++ b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/AttributesSaveRequestTest.java @@ -0,0 +1,68 @@ +/** + * 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. + */ +package org.thingsboard.rule.engine.api; + +import org.junit.jupiter.api.Test; +import org.thingsboard.common.util.NoOpFutureCallback; + +import static org.assertj.core.api.Assertions.assertThat; + +class AttributesSaveRequestTest { + + @Test + void testDefaultProcessingStrategyIsProcessAll() { + var request = AttributesSaveRequest.builder().build(); + + assertThat(request.getStrategy()).isEqualTo(AttributesSaveRequest.Strategy.PROCESS_ALL); + } + + @Test + void testNullProcessingStrategyIsProcessAll() { + var request = AttributesSaveRequest.builder().strategy(null).build(); + + assertThat(request.getStrategy()).isEqualTo(AttributesSaveRequest.Strategy.PROCESS_ALL); + } + + @Test + void testProcessAllStrategy() { + assertThat(AttributesSaveRequest.Strategy.PROCESS_ALL).isEqualTo(new AttributesSaveRequest.Strategy(true, true, true)); + } + + @Test + void testWsOnlyStrategy() { + assertThat(AttributesSaveRequest.Strategy.WS_ONLY).isEqualTo(new AttributesSaveRequest.Strategy(false, true, false)); + } + + @Test + void testSkipAllStrategy() { + assertThat(AttributesSaveRequest.Strategy.SKIP_ALL).isEqualTo(new AttributesSaveRequest.Strategy(false, false, false)); + } + + @Test + void testDefaultCallbackIsNoOp() { + var request = AttributesSaveRequest.builder().build(); + + assertThat(request.getCallback()).isEqualTo(NoOpFutureCallback.instance()); + } + + @Test + void testNullCallbackIsNoOp() { + var request = AttributesSaveRequest.builder().callback(null).build(); + + assertThat(request.getCallback()).isEqualTo(NoOpFutureCallback.instance()); + } + +} diff --git a/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/TimeseriesSaveRequestTest.java b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/TimeseriesSaveRequestTest.java index ecefcca1ea..c0eb04152a 100644 --- a/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/TimeseriesSaveRequestTest.java +++ b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/TimeseriesSaveRequestTest.java @@ -16,36 +16,58 @@ package org.thingsboard.rule.engine.api; import org.junit.jupiter.api.Test; +import org.thingsboard.common.util.NoOpFutureCallback; import static org.assertj.core.api.Assertions.assertThat; class TimeseriesSaveRequestTest { @Test - void testDefaultSaveStrategyIsSaveAll() { + void testDefaultProcessingStrategyIsProcessAll() { var request = TimeseriesSaveRequest.builder().build(); - assertThat(request.getStrategy()).isEqualTo(TimeseriesSaveRequest.Strategy.SAVE_ALL); + assertThat(request.getStrategy()).isEqualTo(TimeseriesSaveRequest.Strategy.PROCESS_ALL); } @Test - void testSaveAllStrategy() { - assertThat(TimeseriesSaveRequest.Strategy.SAVE_ALL).isEqualTo(new TimeseriesSaveRequest.Strategy(true, true, true)); + void testNullProcessingStrategyIsProcessAll() { + var request = TimeseriesSaveRequest.builder().strategy(null).build(); + + assertThat(request.getStrategy()).isEqualTo(TimeseriesSaveRequest.Strategy.PROCESS_ALL); + } + + @Test + void testProcessAllStrategy() { + assertThat(TimeseriesSaveRequest.Strategy.PROCESS_ALL).isEqualTo(new TimeseriesSaveRequest.Strategy(true, true, true, true)); } @Test void testWsOnlyStrategy() { - assertThat(TimeseriesSaveRequest.Strategy.WS_ONLY).isEqualTo(new TimeseriesSaveRequest.Strategy(false, false, true)); + assertThat(TimeseriesSaveRequest.Strategy.WS_ONLY).isEqualTo(new TimeseriesSaveRequest.Strategy(false, false, true, false)); } @Test void testLatestAndWsStrategy() { - assertThat(TimeseriesSaveRequest.Strategy.LATEST_AND_WS).isEqualTo(new TimeseriesSaveRequest.Strategy(false, true, true)); + assertThat(TimeseriesSaveRequest.Strategy.LATEST_AND_WS).isEqualTo(new TimeseriesSaveRequest.Strategy(false, true, true, false)); } @Test void testSkipAllStrategy() { - assertThat(TimeseriesSaveRequest.Strategy.SKIP_ALL).isEqualTo(new TimeseriesSaveRequest.Strategy(false, false, false)); + assertThat(TimeseriesSaveRequest.Strategy.SKIP_ALL).isEqualTo(new TimeseriesSaveRequest.Strategy(false, false, false, false)); + } + + @Test + void testDefaultCallbackIsNoOp() { + var request = TimeseriesSaveRequest.builder().build(); + + assertThat(request.getCallback()).isEqualTo(NoOpFutureCallback.instance()); + } + + @Test + void testNullCallbackIsNoOp() { + var request = TimeseriesSaveRequest.builder().callback(null).build(); + + assertThat(request.getCallback()).isEqualTo(NoOpFutureCallback.instance()); } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNode.java index c78bc77d72..6a806e7bc1 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNode.java @@ -69,10 +69,8 @@ public class TbClearAlarmNode extends TbAbstractAlarmNode clearAlarm(TbContext ctx, TbMsg msg, Alarm alarm) { - ctx.logJsEvalRequest(); ListenableFuture asyncDetails = buildAlarmDetails(msg, alarm.getDetails()); return Futures.transform(asyncDetails, details -> { - ctx.logJsEvalResponse(); AlarmApiCallResult result = ctx.getAlarmService().clearAlarm(ctx.getTenantId(), alarm.getId(), System.currentTimeMillis(), details); if (result.isSuccessful()) { return new TbAlarmResult(false, false, result.isCleared(), result.getAlarm()); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java index 837c5ba692..78411d7cec 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java @@ -118,15 +118,11 @@ public class TbCreateAlarmNode extends TbAbstractAlarmNode asyncDetails; boolean buildDetails = !config.isUseMessageAlarmData() || config.isOverwriteAlarmDetails(); if (buildDetails) { - ctx.logJsEvalRequest(); asyncDetails = buildAlarmDetails(msg, null); } else { asyncDetails = Futures.immediateFuture(null); } ListenableFuture asyncAlarm = Futures.transform(asyncDetails, details -> { - if (buildDetails) { - ctx.logJsEvalResponse(); - } Alarm newAlarm; if (msgAlarm != null) { newAlarm = msgAlarm; @@ -147,15 +143,11 @@ public class TbCreateAlarmNode extends TbAbstractAlarmNode asyncDetails; boolean buildDetails = !config.isUseMessageAlarmData() || config.isOverwriteAlarmDetails(); if (buildDetails) { - ctx.logJsEvalRequest(); asyncDetails = buildAlarmDetails(msg, existingAlarm.getDetails()); } else { asyncDetails = Futures.immediateFuture(null); } ListenableFuture asyncUpdated = Futures.transform(asyncDetails, details -> { - if (buildDetails) { - ctx.logJsEvalResponse(); - } if (msgAlarm != null) { existingAlarm.setSeverity(msgAlarm.getSeverity()); existingAlarm.setPropagate(msgAlarm.isPropagate()); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeviceStateNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeviceStateNode.java index 3aed05d372..c9a50cf88d 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeviceStateNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeviceStateNode.java @@ -17,7 +17,7 @@ package org.thingsboard.rule.engine.action; import lombok.extern.slf4j.Slf4j; import org.springframework.util.ConcurrentReferenceHashMap; -import org.thingsboard.rule.engine.api.RuleEngineDeviceStateManager; +import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; @@ -119,7 +119,7 @@ public class TbDeviceStateNode implements TbNode { TenantId tenantId = ctx.getTenantId(); long eventTs = msg.getMetaDataTs(); - RuleEngineDeviceStateManager deviceStateManager = ctx.getDeviceStateManager(); + DeviceStateManager deviceStateManager = ctx.getDeviceStateManager(); TbCallback callback = getMsgEnqueuedCallback(ctx, msg); switch (event) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbLogNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbLogNode.java index 7e241832cf..47ecac154f 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbLogNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbLogNode.java @@ -75,18 +75,15 @@ public class TbLogNode implements TbNode { return; } - ctx.logJsEvalRequest(); Futures.addCallback(scriptEngine.executeToStringAsync(msg), new FutureCallback() { @Override public void onSuccess(@Nullable String result) { - ctx.logJsEvalResponse(); log.info(result); ctx.tellSuccess(msg); } @Override public void onFailure(Throwable t) { - ctx.logJsEvalResponse(); ctx.tellFailure(msg, t); } }, MoreExecutors.directExecutor()); //usually js responses runs on js callback executor diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNode.java index 2848e7c45a..90ee0c9048 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNode.java @@ -160,10 +160,8 @@ public class TbMsgGeneratorNode implements TbNode { prevMsg = ctx.newMsg(queueName, TbMsg.EMPTY_STRING, originatorId, msg.getCustomerId(), TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); } if (initialized.get()) { - ctx.logJsEvalRequest(); return Futures.transformAsync(scriptEngine.executeGenerateAsync(prevMsg), generated -> { log.trace("generate process response, generated {}, config {}", generated, config); - ctx.logJsEvalResponse(); prevMsg = ctx.newMsg(queueName, generated.getType(), originatorId, msg.getCustomerId(), generated.getMetaData(), generated.getData()); return Futures.immediateFuture(prevMsg); }, MoreExecutors.directExecutor()); //usually it runs on js-executor-remote-callback thread pool diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsFilterNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsFilterNode.java index 50f168a5af..e36494a2b6 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsFilterNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsFilterNode.java @@ -60,15 +60,12 @@ public class TbJsFilterNode implements TbNode { @Override public void onMsg(TbContext ctx, TbMsg msg) { - ctx.logJsEvalRequest(); withCallback(scriptEngine.executeFilterAsync(msg), filterResult -> { - ctx.logJsEvalResponse(); ctx.tellNext(msg, filterResult ? TbNodeConnectionType.TRUE : TbNodeConnectionType.FALSE); }, t -> { ctx.tellFailure(msg, t); - ctx.logJsEvalFailure(); }, ctx.getDbCallbackExecutor()); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsSwitchNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsSwitchNode.java index 3c27740c84..e039eaeb14 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsSwitchNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsSwitchNode.java @@ -61,17 +61,14 @@ public class TbJsSwitchNode implements TbNode { @Override public void onMsg(TbContext ctx, TbMsg msg) { - ctx.logJsEvalRequest(); Futures.addCallback(scriptEngine.executeSwitchAsync(msg), new FutureCallback<>() { @Override public void onSuccess(@Nullable Set result) { - ctx.logJsEvalResponse(); processSwitch(ctx, msg, result); } @Override public void onFailure(Throwable t) { - ctx.logJsEvalFailure(); ctx.tellFailure(msg, t); } }, MoreExecutors.directExecutor()); //usually runs in a callbackExecutor diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNode.java index 6008305570..d544d0647d 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNode.java @@ -15,6 +15,8 @@ */ package org.thingsboard.rule.engine.kafka; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.BooleanUtils; import org.apache.kafka.clients.producer.KafkaProducer; @@ -26,6 +28,7 @@ import org.apache.kafka.common.config.SslConfigs; import org.apache.kafka.common.header.Headers; import org.apache.kafka.common.header.internals.RecordHeader; import org.apache.kafka.common.header.internals.RecordHeaders; +import org.apache.kafka.common.serialization.StringSerializer; import org.springframework.util.ReflectionUtils; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -35,6 +38,7 @@ import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.rule.engine.external.TbAbstractExternalNode; import org.thingsboard.server.common.data.exception.ThingsboardKafkaClientError; import org.thingsboard.server.common.data.plugin.ComponentType; +import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; @@ -48,6 +52,7 @@ import java.util.Properties; type = ComponentType.EXTERNAL, name = "kafka", configClazz = TbKafkaNodeConfiguration.class, + version = 1, nodeDescription = "Publish messages to Kafka server", nodeDetails = "Will send record via Kafka producer to Kafka server. " + "Outbound message will contain response fields (offset, partition, topic)" + @@ -83,8 +88,8 @@ public class TbKafkaNode extends TbAbstractExternalNode { Properties properties = new Properties(); properties.put(ProducerConfig.CLIENT_ID_CONFIG, "producer-tb-kafka-node-" + ctx.getSelfId().getId().toString() + "-" + ctx.getServiceId()); properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, config.getBootstrapServers()); - properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, config.getValueSerializer()); - properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, config.getKeySerializer()); + properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); properties.put(ProducerConfig.ACKS_CONFIG, config.getAcks()); properties.put(ProducerConfig.RETRIES_CONFIG, config.getRetries()); properties.put(ProducerConfig.BATCH_SIZE_CONFIG, config.getBatchSize()); @@ -200,4 +205,22 @@ public class TbKafkaNode extends TbAbstractExternalNode { .build(); } + @Override + public TbPair upgrade(int fromVersion, JsonNode oldConfiguration) throws TbNodeException { + boolean hasChanges = false; + switch (fromVersion) { + case 0 -> { + if (oldConfiguration.has("keySerializer") || oldConfiguration.has("valueSerializer")) { + ObjectNode objectConfiguration = (ObjectNode) oldConfiguration; + objectConfiguration.remove("keySerializer"); + objectConfiguration.remove("valueSerializer"); + hasChanges = true; + } + } + default -> { + } + } + return new TbPair<>(hasChanges, oldConfiguration); + } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNodeConfiguration.java index 867dd2e54e..9a31e58ce7 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNodeConfiguration.java @@ -16,7 +16,6 @@ package org.thingsboard.rule.engine.kafka; import lombok.Data; -import org.apache.kafka.common.serialization.StringSerializer; import org.thingsboard.rule.engine.api.NodeConfiguration; import java.util.Collections; @@ -33,8 +32,6 @@ public class TbKafkaNodeConfiguration implements NodeConfiguration otherProperties; private boolean addMetadataKeyValuesAsKafkaHeaders; @@ -50,8 +47,6 @@ public class TbKafkaNodeConfiguration implements NodeConfigurationsave attributes and save time series nodes. " + + "This rule node accepts the same messages as these nodes but allows you to trigger the processing of calculated " + + "fields independently, ensuring that derived data can be computed and utilized in real time without storing the original message in the database.", + configDirective = "tbNodeEmptyConfig", + icon = "published_with_changes" +) +public class TbCalculatedFieldsNode implements TbNode { + + private EmptyNodeConfiguration config; + + @Override + public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { + this.config = TbNodeUtils.convert(configuration, EmptyNodeConfiguration.class); + } + + @Override + public void onMsg(TbContext ctx, TbMsg msg) { + switch (msg.getInternalType()) { + case POST_TELEMETRY_REQUEST -> processPostTelemetryRequest(ctx, msg); + case POST_ATTRIBUTES_REQUEST -> processPostAttributesRequest(ctx, msg); + default -> ctx.tellFailure(msg, new IllegalArgumentException("Unsupported msg type: " + msg.getType())); + } + } + + private void processPostTelemetryRequest(TbContext ctx, TbMsg msg) { + Map> tsKvMap = JsonConverter.convertToTelemetry(JsonParser.parseString(msg.getData()), System.currentTimeMillis()); + + if (tsKvMap.isEmpty()) { + ctx.tellSuccess(msg); + return; + } + + List tsKvEntryList = new ArrayList<>(); + for (Map.Entry> tsKvEntry : tsKvMap.entrySet()) { + for (KvEntry kvEntry : tsKvEntry.getValue()) { + tsKvEntryList.add(new BasicTsKvEntry(tsKvEntry.getKey(), kvEntry)); + } + } + + TimeseriesSaveRequest timeseriesSaveRequest = TimeseriesSaveRequest.builder() + .tenantId(ctx.getTenantId()) + .customerId(msg.getCustomerId()) + .entityId(msg.getOriginator()) + .entries(tsKvEntryList) + .previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds()) + .tbMsgId(msg.getId()) + .tbMsgType(msg.getInternalType()) + .callback(new TelemetryNodeCallback(ctx, msg)) + .build(); + + ctx.getCalculatedFieldQueueService().pushRequestToQueue(timeseriesSaveRequest, timeseriesSaveRequest.getCallback()); + } + + private void processPostAttributesRequest(TbContext ctx, TbMsg msg) { + List newAttributes = new ArrayList<>(JsonConverter.convertToAttributes(JsonParser.parseString(msg.getData()))); + + if (newAttributes.isEmpty()) { + ctx.tellSuccess(msg); + return; + } + + AttributesSaveRequest attributesSaveRequest = AttributesSaveRequest.builder() + .tenantId(ctx.getTenantId()) + .entityId(msg.getOriginator()) + .scope(AttributeScope.valueOf(msg.getMetaData().getValue(SCOPE))) + .entries(newAttributes) + .previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds()) + .tbMsgId(msg.getId()) + .tbMsgType(msg.getInternalType()) + .callback(new TelemetryNodeCallback(ctx, msg)) + .build(); + ctx.getCalculatedFieldQueueService().pushRequestToQueue(attributesSaveRequest, attributesSaveRequest.getCallback()); + } + +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java index da6d82707a..c04f5b474d 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java @@ -23,6 +23,7 @@ import com.google.common.util.concurrent.MoreExecutors; import com.google.gson.JsonParser; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.DonAsynchron; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.AttributesSaveRequest; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -30,6 +31,7 @@ import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.util.TbNodeUtils; +import org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings; import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.StringUtils; @@ -43,9 +45,14 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; +import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.Advanced; +import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.Deduplicate; +import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.OnEveryMessage; +import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.WebSocketsOnly; import static org.thingsboard.server.common.data.DataConstants.NOTIFY_DEVICE_METADATA_KEY; import static org.thingsboard.server.common.data.DataConstants.SCOPE; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_REQUEST; @@ -55,13 +62,51 @@ import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_R type = ComponentType.ACTION, name = "save attributes", configClazz = TbMsgAttributesNodeConfiguration.class, - version = 2, - nodeDescription = "Saves attributes data", - nodeDetails = "Saves entity attributes based on configurable scope parameter. Expects messages with 'POST_ATTRIBUTES_REQUEST' message type. " + - "If upsert(update/insert) operation is completed successfully rule node will send the incoming message via Success chain, otherwise, Failure chain is used. " + - "Additionally if checkbox Send attributes updated notification is set to true, rule node will put the \"Attributes Updated\" " + - "event for SHARED_SCOPE and SERVER_SCOPE attributes updates to the corresponding rule engine queue." + - "Performance checkbox 'Save attributes only if the value changes' will skip attributes overwrites for values with no changes (avoid concurrent writes because this check is not transactional; will not update 'Last updated time' for skipped attributes).", + version = 3, + nodeDescription = """ + Saves attribute data with a configurable scope and according to configured processing strategies. + """, + nodeDetails = """ + Node performs three actions: +
    +
  • Attributes: save attribute data to a database.
  • +
  • WebSockets: notify WebSockets subscriptions about attribute data updates.
  • +
  • Calculated fields: notify calculated fields about attribute data updates.
  • +
+ + For each action, three processing strategies are available: +
    +
  • On every message: perform the action for every message.
  • +
  • Deduplicate: perform the action only for the first message from a particular originator within a configurable interval.
  • +
  • Skip: never perform the action.
  • +
+ + Processing strategies are configured using processing settings, which support two modes: +
    +
  • Basic +
      +
    • On every message: applies the "On every message" strategy to all actions.
    • +
    • Deduplicate: applies the "Deduplicate" strategy (with a specified interval) to all actions.
    • +
    • WebSockets only: for all actions except WebSocket notifications, the "Skip" strategy is applied, while WebSocket notifications use the "On every message" strategy.
    • +
    +
  • +
  • Advanced: configure each action’s strategy independently.
  • +
+ + The node supports three attribute scopes: Client attributes, Shared attributes, and Server attributes. + You can set the default scope in the node configuration, or override it by specifying a valid scope property in the message metadata. +

+ Additionally: +
    +
  • If Save attributes only if the value changes is enabled, the rule node compares the received attribute value with the current stored value and skips the save operation if they match.
  • +
  • If Send attributes updated notification is enabled, the rule node will put the Attributes Updated event for SHARED_SCOPE and SERVER_SCOPE attribute updates to the queue named Main.
  • +
  • If Force notification to the device is enabled, then rule node will always notify device about SHARED_SCOPE attribute updates, regardless of the value of notifyDevice metadata property.
  • +
+ + This node expects messages of type POST_ATTRIBUTES_REQUEST. +

+ Output connections: Success, Failure. + """, configDirective = "tbActionNodeAttributesConfig", icon = "file_upload" ) @@ -73,9 +118,12 @@ public class TbMsgAttributesNode implements TbNode { private TbMsgAttributesNodeConfiguration config; + private AttributesProcessingSettings processingSettings; + @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbMsgAttributesNodeConfiguration.class); + config = TbNodeUtils.convert(configuration, TbMsgAttributesNodeConfiguration.class); + processingSettings = config.getProcessingSettings(); } @Override @@ -90,11 +138,20 @@ public class TbMsgAttributesNode implements TbNode { ctx.tellSuccess(msg); return; } + + AttributesSaveRequest.Strategy strategy = determineSaveStrategy(msg.getMetaDataTs(), msg.getOriginator().getId()); + + // short-circuit + if (!strategy.saveAttributes() && !strategy.sendWsUpdate() && !strategy.processCalculatedFields()) { + ctx.tellSuccess(msg); + return; + } + AttributeScope scope = getScope(msg.getMetaData().getValue(SCOPE)); boolean sendAttributesUpdateNotification = checkSendNotification(scope); if (!config.isUpdateAttributesOnlyOnValueChange()) { - saveAttr(newAttributes, ctx, msg, scope, sendAttributesUpdateNotification); + saveAttr(newAttributes, ctx, msg, scope, sendAttributesUpdateNotification, strategy); return; } @@ -104,13 +161,42 @@ public class TbMsgAttributesNode implements TbNode { DonAsynchron.withCallback(findFuture, currentAttributes -> { List attributesChanged = filterChangedAttr(currentAttributes, newAttributes); - saveAttr(attributesChanged, ctx, msg, scope, sendAttributesUpdateNotification); + saveAttr(attributesChanged, ctx, msg, scope, sendAttributesUpdateNotification, strategy); }, throwable -> ctx.tellFailure(msg, throwable), MoreExecutors.directExecutor()); } - void saveAttr(List attributes, TbContext ctx, TbMsg msg, AttributeScope scope, boolean sendAttributesUpdateNotification) { + private AttributesSaveRequest.Strategy determineSaveStrategy(long ts, UUID originatorUuid) { + if (processingSettings instanceof OnEveryMessage) { + return AttributesSaveRequest.Strategy.PROCESS_ALL; + } + if (processingSettings instanceof WebSocketsOnly) { + return AttributesSaveRequest.Strategy.WS_ONLY; + } + if (processingSettings instanceof Deduplicate deduplicate) { + boolean isFirstMsgInInterval = deduplicate.getProcessingStrategy().shouldProcess(ts, originatorUuid); + return isFirstMsgInInterval ? AttributesSaveRequest.Strategy.PROCESS_ALL : AttributesSaveRequest.Strategy.SKIP_ALL; + } + if (processingSettings instanceof Advanced advanced) { + return new AttributesSaveRequest.Strategy( + advanced.attributes().shouldProcess(ts, originatorUuid), + advanced.webSockets().shouldProcess(ts, originatorUuid), + advanced.calculatedFields().shouldProcess(ts, originatorUuid) + ); + } + // should not happen + throw new IllegalArgumentException("Unknown processing settings type: " + processingSettings.getClass().getSimpleName()); + } + + private void saveAttr( + List attributes, + TbContext ctx, + TbMsg msg, + AttributeScope scope, + boolean sendAttributesUpdateNotification, + AttributesSaveRequest.Strategy strategy + ) { if (attributes.isEmpty()) { ctx.tellSuccess(msg); return; @@ -124,11 +210,15 @@ public class TbMsgAttributesNode implements TbNode { .scope(scope) .entries(attributes) .notifyDevice(config.isNotifyDevice() || checkNotifyDeviceMdValue(msg.getMetaData().getValue(NOTIFY_DEVICE_METADATA_KEY))) + .strategy(strategy) + .previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds()) + .tbMsgId(msg.getId()) + .tbMsgType(msg.getInternalType()) .callback(callback) .build()); } - List filterChangedAttr(List currentAttributes, List newAttributes) { + private List filterChangedAttr(List currentAttributes, List newAttributes) { if (currentAttributes == null || currentAttributes.isEmpty()) { return newAttributes; } @@ -178,6 +268,9 @@ public class TbMsgAttributesNode implements TbNode { hasChanges = fixEscapedBooleanConfigParameter(oldConfiguration, SEND_ATTRIBUTES_UPDATED_NOTIFICATION_KEY, hasChanges, false); // update updateAttributesOnlyOnValueChange. hasChanges = fixEscapedBooleanConfigParameter(oldConfiguration, UPDATE_ATTRIBUTES_ONLY_ON_VALUE_CHANGE_KEY, hasChanges, true); + case 2: + hasChanges = true; + ((ObjectNode) oldConfiguration).set("processingSettings", JacksonUtil.valueToTree(new OnEveryMessage())); break; default: break; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeConfiguration.java index 161aa64d5f..2687125a0c 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeConfiguration.java @@ -15,13 +15,20 @@ */ package org.thingsboard.rule.engine.telemetry; +import jakarta.validation.constraints.NotNull; import lombok.Data; import org.thingsboard.rule.engine.api.NodeConfiguration; +import org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings; import org.thingsboard.server.common.data.DataConstants; +import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.OnEveryMessage; + @Data public class TbMsgAttributesNodeConfiguration implements NodeConfiguration { + @NotNull + private AttributesProcessingSettings processingSettings; + private String scope; private boolean notifyDevice; @@ -31,6 +38,7 @@ public class TbMsgAttributesNodeConfiguration implements NodeConfigurationactions: + Node performs four actions:
  • Time series: save time series data to a ts_kv table in a DB.
  • Latest values: save time series data to a ts_kv_latest table in a DB.
  • WebSockets: notify WebSockets subscriptions about time series data updates.
  • +
  • Calculated fields: notify calculated fields about time series data updates.
For each action, three processing strategies are available: @@ -81,7 +82,7 @@ import static org.thingsboard.server.common.data.msg.TbMsgType.POST_TELEMETRY_RE
  • On every message: applies the "On every message" strategy to all actions.
  • Deduplicate: applies the "Deduplicate" strategy (with a specified interval) to all actions.
  • -
  • WebSockets only: applies the "Skip" strategy to Time series and Latest values, and the "On every message" strategy to WebSockets.
  • +
  • WebSockets only: for all actions except WebSocket notifications, the "Skip" strategy is applied, while WebSocket notifications use the "On every message" strategy.
  • Advanced: configure each action’s strategy independently.
  • @@ -90,7 +91,7 @@ import static org.thingsboard.server.common.data.msg.TbMsgType.POST_TELEMETRY_RE By default, the timestamp is taken from metadata.ts. You can enable Use server timestamp to always use the current server time instead. This is particularly useful in sequential processing scenarios where messages may arrive with out-of-order timestamps from - multiple sources. Note that the DB layer may ignore older records for attributes and latest values, + multiple sources. Note that the DB layer may ignore "outdated" records for attributes and latest values, so enabling Use server timestamp can ensure correct ordering.

    The TTL is taken first from metadata.TTL. If absent, the node configuration’s default @@ -110,7 +111,7 @@ public class TbMsgTimeseriesNode implements TbNode { private TbContext ctx; private long tenantProfileDefaultStorageTtl; - private ProcessingSettings processingSettings; + private TimeseriesProcessingSettings processingSettings; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { @@ -137,7 +138,7 @@ public class TbMsgTimeseriesNode implements TbNode { TimeseriesSaveRequest.Strategy strategy = determineSaveStrategy(ts, msg.getOriginator().getId()); // short-circuit - if (!strategy.saveTimeseries() && !strategy.saveLatest() && !strategy.sendWsUpdate()) { + if (!strategy.saveTimeseries() && !strategy.saveLatest() && !strategy.sendWsUpdate() && !strategy.processCalculatedFields()) { ctx.tellSuccess(msg); return; } @@ -166,6 +167,9 @@ public class TbMsgTimeseriesNode implements TbNode { .entries(tsKvEntryList) .ttl(ttl) .strategy(strategy) + .previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds()) + .tbMsgId(msg.getId()) + .tbMsgType(msg.getInternalType()) .callback(new TelemetryNodeCallback(ctx, msg)) .build()); } @@ -176,20 +180,21 @@ public class TbMsgTimeseriesNode implements TbNode { private TimeseriesSaveRequest.Strategy determineSaveStrategy(long ts, UUID originatorUuid) { if (processingSettings instanceof OnEveryMessage) { - return TimeseriesSaveRequest.Strategy.SAVE_ALL; + return TimeseriesSaveRequest.Strategy.PROCESS_ALL; } if (processingSettings instanceof WebSocketsOnly) { return TimeseriesSaveRequest.Strategy.WS_ONLY; } if (processingSettings instanceof Deduplicate deduplicate) { boolean isFirstMsgInInterval = deduplicate.getProcessingStrategy().shouldProcess(ts, originatorUuid); - return isFirstMsgInInterval ? TimeseriesSaveRequest.Strategy.SAVE_ALL : TimeseriesSaveRequest.Strategy.SKIP_ALL; + return isFirstMsgInInterval ? TimeseriesSaveRequest.Strategy.PROCESS_ALL : TimeseriesSaveRequest.Strategy.SKIP_ALL; } if (processingSettings instanceof Advanced advanced) { return new TimeseriesSaveRequest.Strategy( advanced.timeseries().shouldProcess(ts, originatorUuid), advanced.latest().shouldProcess(ts, originatorUuid), - advanced.webSockets().shouldProcess(ts, originatorUuid) + advanced.webSockets().shouldProcess(ts, originatorUuid), + advanced.calculatedFields().shouldProcess(ts, originatorUuid) ); } // should not happen @@ -212,6 +217,7 @@ public class TbMsgTimeseriesNode implements TbNode { var skipLatestProcessingSettings = new Advanced( ProcessingStrategy.onEveryMessage(), ProcessingStrategy.skip(), + ProcessingStrategy.onEveryMessage(), ProcessingStrategy.onEveryMessage() ); ((ObjectNode) oldConfiguration).set("processingSettings", JacksonUtil.valueToTree(skipLatestProcessingSettings)); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeConfiguration.java index ce41e08475..48264978cf 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeConfiguration.java @@ -15,23 +15,12 @@ */ package org.thingsboard.rule.engine.telemetry; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonSubTypes; -import com.fasterxml.jackson.annotation.JsonTypeInfo; import jakarta.validation.constraints.NotNull; import lombok.Data; -import lombok.Getter; import org.thingsboard.rule.engine.api.NodeConfiguration; -import org.thingsboard.rule.engine.telemetry.strategy.ProcessingStrategy; +import org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings; -import java.util.Objects; - -import static org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Advanced; -import static org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Deduplicate; -import static org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNodeConfiguration.ProcessingSettings.OnEveryMessage; -import static org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNodeConfiguration.ProcessingSettings.WebSocketsOnly; +import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.OnEveryMessage; @Data public class TbMsgTimeseriesNodeConfiguration implements NodeConfiguration { @@ -39,7 +28,7 @@ public class TbMsgTimeseriesNodeConfiguration implements NodeConfiguration> transform(TbContext ctx, TbMsg msg) { - ctx.logJsEvalRequest(); return scriptEngine.executeUpdateAsync(msg); } @Override protected void transformFailure(TbContext ctx, TbMsg msg, Throwable t) { - ctx.logJsEvalFailure(); super.transformFailure(ctx, msg, t); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java index e035fa31c0..f12a856567 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java @@ -18,10 +18,13 @@ package org.thingsboard.rule.engine.util; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.ApiUsageStateId; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.CalculatedFieldLinkId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.DeviceId; @@ -161,6 +164,17 @@ public class TenantIdLoader { case MOBILE_APP_BUNDLE: tenantEntity = ctx.getMobileAppBundleService().findMobileAppBundleById(ctxTenantId, new MobileAppBundleId(id)); break; + case CALCULATED_FIELD: + tenantEntity = ctx.getCalculatedFieldService().findById(ctxTenantId, new CalculatedFieldId(id)); + break; + case CALCULATED_FIELD_LINK: + CalculatedFieldLink calculatedFieldLink = ctx.getCalculatedFieldService().findCalculatedFieldLinkById(ctxTenantId, new CalculatedFieldLinkId(id)); + if (calculatedFieldLink != null) { + tenantEntity = ctx.getCalculatedFieldService().findById(ctxTenantId, calculatedFieldLink.getCalculatedFieldId()); + } else { + tenantEntity = null; + } + break; default: throw new RuntimeException("Unexpected entity type: " + entityId.getEntityType()); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java index 1d4c55a780..6cb8299b67 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java @@ -119,8 +119,8 @@ class TbCreateAlarmNodeTest { delete metadata.prevAlarmDetails; //now metadata is the same as it comes IN this rule node } - - + + return details;"""); assertThat(config.getAlarmDetailsBuildTbel()).isEqualTo(""" \ @@ -131,8 +131,8 @@ class TbCreateAlarmNodeTest { metadata.remove('prevAlarmDetails'); //now metadata is the same as it comes IN this rule node } - - + + return details;"""); assertThat(config.getSeverity()).isEqualTo(AlarmSeverity.CRITICAL.name()); assertThat(config.isPropagate()).isFalse(); @@ -247,9 +247,7 @@ class TbCreateAlarmNodeTest { // THEN // verify alarm details script evaluation - then(ctxMock).should().logJsEvalRequest(); then(alarmDetailsScriptMock).should().executeJsonAsync(incomingMsg); - then(ctxMock).should().logJsEvalResponse(); // verify we called createAlarm() with correct AlarmCreateOrUpdateActiveRequest then(alarmServiceMock).should().createAlarm(expectedCreateAlarmRequest); @@ -421,9 +419,7 @@ class TbCreateAlarmNodeTest { // THEN // verify alarm details script evaluation - then(ctxMock).should().logJsEvalRequest(); then(alarmDetailsScriptMock).should().executeJsonAsync(incomingMsg); - then(ctxMock).should().logJsEvalResponse(); // verify we called createAlarm() with correct AlarmCreateOrUpdateActiveRequest then(alarmServiceMock).should().createAlarm(expectedCreateAlarmRequest); @@ -616,14 +612,12 @@ class TbCreateAlarmNodeTest { // THEN // verify alarm details script evaluation - then(ctxMock).should().logJsEvalRequest(); var dummyMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); then(alarmDetailsScriptMock).should().executeJsonAsync(dummyMsgCaptor.capture()); TbMsg actualDummyMsg = dummyMsgCaptor.getValue(); assertThat(actualDummyMsg.getType()).isEqualTo(incomingMsg.getType()); assertThat(actualDummyMsg.getData()).isEqualTo(incomingMsg.getData()); assertThat(actualDummyMsg.getMetaData().getData()).containsEntry("prevAlarmDetails", JacksonUtil.toString(oldAlarmDetails)); - then(ctxMock).should().logJsEvalResponse(); // verify we called updateAlarm() with correct AlarmUpdateRequest then(alarmServiceMock).should().updateAlarm(expectedUpdateAlarmRequest); @@ -793,9 +787,7 @@ class TbCreateAlarmNodeTest { // THEN // verify alarm details script was not evaluated - then(ctxMock).should(never()).logJsEvalRequest(); then(alarmDetailsScriptMock).should(never()).executeJsonAsync(any()); - then(ctxMock).should(never()).logJsEvalResponse(); // verify we called createAlarm() with correct AlarmCreateOrUpdateActiveRequest then(alarmServiceMock).should().createAlarm(expectedCreateAlarmRequest); @@ -985,14 +977,12 @@ class TbCreateAlarmNodeTest { // THEN // verify alarm details script evaluation - then(ctxMock).should().logJsEvalRequest(); var dummyMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); then(alarmDetailsScriptMock).should().executeJsonAsync(dummyMsgCaptor.capture()); TbMsg actualDummyMsg = dummyMsgCaptor.getValue(); assertThat(actualDummyMsg.getType()).isEqualTo(incomingMsg.getType()); assertThat(actualDummyMsg.getData()).isEqualTo(incomingMsg.getData()); assertThat(actualDummyMsg.getMetaData().getData()).containsEntry("prevAlarmDetails", JacksonUtil.toString(oldAlarmDetails)); - then(ctxMock).should().logJsEvalResponse(); // verify we called updateAlarm() with correct AlarmUpdateRequest then(alarmServiceMock).should().updateAlarm(expectedUpdateAlarmRequest); @@ -1171,14 +1161,12 @@ class TbCreateAlarmNodeTest { // THEN // verify alarm details script evaluation - then(ctxMock).should().logJsEvalRequest(); var dummyMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); then(alarmDetailsScriptMock).should().executeJsonAsync(dummyMsgCaptor.capture()); TbMsg actualDummyMsg = dummyMsgCaptor.getValue(); assertThat(actualDummyMsg.getType()).isEqualTo(incomingMsg.getType()); assertThat(actualDummyMsg.getData()).isEqualTo(incomingMsg.getData()); assertThat(actualDummyMsg.getMetaData().getData()).containsEntry("prevAlarmDetails", JacksonUtil.toString(alarmDetails)); - then(ctxMock).should().logJsEvalResponse(); // verify we called updateAlarm() with correct AlarmUpdateRequest then(alarmServiceMock).should().updateAlarm(expectedUpdateAlarmRequest); diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbDeviceStateNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbDeviceStateNodeTest.java index b31dffdc01..2c3c61150e 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbDeviceStateNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbDeviceStateNodeTest.java @@ -29,7 +29,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.util.ConcurrentReferenceHashMap; import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.rule.engine.api.RuleEngineDeviceStateManager; +import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; @@ -66,7 +66,7 @@ public class TbDeviceStateNodeTest { @Mock private TbContext ctxMock; @Mock - private RuleEngineDeviceStateManager deviceStateManagerMock; + private DeviceStateManager deviceStateManagerMock; @Captor private ArgumentCaptor callbackCaptor; private TbDeviceStateNode node; @@ -263,7 +263,7 @@ public class TbDeviceStateNodeTest { @ParameterizedTest @MethodSource - public void givenSupportedEventAndDeviceOriginator_whenOnMsg_thenCorrectEventIsSentWithCorrectCallback(TbMsgType supportedEventType, BiConsumer> actionVerification) { + public void givenSupportedEventAndDeviceOriginator_whenOnMsg_thenCorrectEventIsSentWithCorrectCallback(TbMsgType supportedEventType, BiConsumer> actionVerification) { // GIVEN given(ctxMock.getTenantId()).willReturn(TENANT_ID); given(ctxMock.getDeviceStateNodeRateLimitConfig()).willReturn("1:1"); @@ -297,10 +297,10 @@ public class TbDeviceStateNodeTest { private static Stream givenSupportedEventAndDeviceOriginator_whenOnMsg_thenCorrectEventIsSentWithCorrectCallback() { return Stream.of( - Arguments.of(TbMsgType.CONNECT_EVENT, (BiConsumer>) (deviceStateManagerMock, callbackCaptor) -> then(deviceStateManagerMock).should().onDeviceConnect(eq(TENANT_ID), eq(DEVICE_ID), eq(METADATA_TS), callbackCaptor.capture())), - Arguments.of(TbMsgType.ACTIVITY_EVENT, (BiConsumer>) (deviceStateManagerMock, callbackCaptor) -> then(deviceStateManagerMock).should().onDeviceActivity(eq(TENANT_ID), eq(DEVICE_ID), eq(METADATA_TS), callbackCaptor.capture())), - Arguments.of(TbMsgType.DISCONNECT_EVENT, (BiConsumer>) (deviceStateManagerMock, callbackCaptor) -> then(deviceStateManagerMock).should().onDeviceDisconnect(eq(TENANT_ID), eq(DEVICE_ID), eq(METADATA_TS), callbackCaptor.capture())), - Arguments.of(TbMsgType.INACTIVITY_EVENT, (BiConsumer>) (deviceStateManagerMock, callbackCaptor) -> then(deviceStateManagerMock).should().onDeviceInactivity(eq(TENANT_ID), eq(DEVICE_ID), eq(METADATA_TS), callbackCaptor.capture())) + Arguments.of(TbMsgType.CONNECT_EVENT, (BiConsumer>) (deviceStateManagerMock, callbackCaptor) -> then(deviceStateManagerMock).should().onDeviceConnect(eq(TENANT_ID), eq(DEVICE_ID), eq(METADATA_TS), callbackCaptor.capture())), + Arguments.of(TbMsgType.ACTIVITY_EVENT, (BiConsumer>) (deviceStateManagerMock, callbackCaptor) -> then(deviceStateManagerMock).should().onDeviceActivity(eq(TENANT_ID), eq(DEVICE_ID), eq(METADATA_TS), callbackCaptor.capture())), + Arguments.of(TbMsgType.DISCONNECT_EVENT, (BiConsumer>) (deviceStateManagerMock, callbackCaptor) -> then(deviceStateManagerMock).should().onDeviceDisconnect(eq(TENANT_ID), eq(DEVICE_ID), eq(METADATA_TS), callbackCaptor.capture())), + Arguments.of(TbMsgType.INACTIVITY_EVENT, (BiConsumer>) (deviceStateManagerMock, callbackCaptor) -> then(deviceStateManagerMock).should().onDeviceInactivity(eq(TENANT_ID), eq(DEVICE_ID), eq(METADATA_TS), callbackCaptor.capture())) ); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/kafka/TbKafkaNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/kafka/TbKafkaNodeTest.java index de81d55fdb..486e707571 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/kafka/TbKafkaNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/kafka/TbKafkaNodeTest.java @@ -39,8 +39,10 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ListeningExecutor; +import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; import org.thingsboard.rule.engine.TestDbCallbackExecutor; import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.util.TbNodeUtils; @@ -73,7 +75,7 @@ import static org.mockito.BDDMockito.willReturn; import static org.mockito.BDDMockito.willThrow; @ExtendWith(MockitoExtension.class) -public class TbKafkaNodeTest { +public class TbKafkaNodeTest extends AbstractRuleNodeUpgradeTest { private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("5f2eac08-bd1f-4635-a6c2-437369f996cf")); private final RuleNodeId RULE_NODE_ID = new RuleNodeId(UUID.fromString("d46bb666-ecab-4d89-a28f-5abdca23ac29")); @@ -117,8 +119,6 @@ public class TbKafkaNodeTest { assertThat(config.getLinger()).isEqualTo(0); assertThat(config.getBufferMemory()).isEqualTo(33554432); assertThat(config.getAcks()).isEqualTo("-1"); - assertThat(config.getKeySerializer()).isEqualTo(StringSerializer.class.getName()); - assertThat(config.getValueSerializer()).isEqualTo(StringSerializer.class.getName()); assertThat(config.getOtherProperties()).isEmpty(); assertThat(config.isAddMetadataKeyValuesAsKafkaHeaders()).isFalse(); assertThat(config.getKafkaHeadersCharset()).isEqualTo("UTF-8"); @@ -163,8 +163,8 @@ public class TbKafkaNodeTest { Properties expectedProperties = new Properties(); expectedProperties.put(ProducerConfig.CLIENT_ID_CONFIG, "producer-tb-kafka-node-" + RULE_NODE_ID.getId() + "-" + SERVICE_ID_STR); expectedProperties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, config.getBootstrapServers()); - expectedProperties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, config.getValueSerializer()); - expectedProperties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, config.getKeySerializer()); + expectedProperties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + expectedProperties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); expectedProperties.put(ProducerConfig.ACKS_CONFIG, config.getAcks()); expectedProperties.put(ProducerConfig.RETRIES_CONFIG, config.getRetries()); expectedProperties.put(ProducerConfig.BATCH_SIZE_CONFIG, config.getBatchSize()); @@ -454,4 +454,75 @@ public class TbKafkaNodeTest { assertThat(actualMsg).usingRecursiveComparison().ignoringFields("ctx").isEqualTo(expectedMsg); } + private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { + return Stream.of( + //config for version 0 + Arguments.of(0, + "{\n" + + " \"topicPattern\": \"test-topic\",\n" + + " \"keyPattern\": \"test-key\",\n" + + " \"bootstrapServers\": \"localhost:9092\",\n" + + " \"retries\": 0,\n" + + " \"batchSize\": 16384,\n" + + " \"linger\": 0,\n" + + " \"bufferMemory\": 33554432,\n" + + " \"acks\": \"-1\",\n" + + " \"otherProperties\": {},\n" + + " \"addMetadataKeyValuesAsKafkaHeaders\": false,\n" + + " \"kafkaHeadersCharset\": \"UTF-8\",\n" + + " \"keySerializer\": \"org.apache.kafka.common.serialization.StringSerializer\",\n" + + " \"valueSerializer\": \"org.apache.kafka.common.serialization.StringSerializer\"\n" + + "}", + true, + "{\n" + + " \"topicPattern\": \"test-topic\",\n" + + " \"keyPattern\": \"test-key\",\n" + + " \"bootstrapServers\": \"localhost:9092\",\n" + + " \"retries\": 0,\n" + + " \"batchSize\": 16384,\n" + + " \"linger\": 0,\n" + + " \"bufferMemory\": 33554432,\n" + + " \"acks\": \"-1\",\n" + + " \"otherProperties\": {},\n" + + " \"addMetadataKeyValuesAsKafkaHeaders\": false,\n" + + " \"kafkaHeadersCharset\": \"UTF-8\"\n" + + "}" + ), + //config for version 1 with upgrade from version 0 + Arguments.of(1, + "{\n" + + " \"topicPattern\": \"test-topic\",\n" + + " \"keyPattern\": \"test-key\",\n" + + " \"bootstrapServers\": \"localhost:9092\",\n" + + " \"retries\": 0,\n" + + " \"batchSize\": 16384,\n" + + " \"linger\": 0,\n" + + " \"bufferMemory\": 33554432,\n" + + " \"acks\": \"-1\",\n" + + " \"otherProperties\": {},\n" + + " \"addMetadataKeyValuesAsKafkaHeaders\": false,\n" + + " \"kafkaHeadersCharset\": \"UTF-8\"\n" + + "}", + false, + "{\n" + + " \"topicPattern\": \"test-topic\",\n" + + " \"keyPattern\": \"test-key\",\n" + + " \"bootstrapServers\": \"localhost:9092\",\n" + + " \"retries\": 0,\n" + + " \"batchSize\": 16384,\n" + + " \"linger\": 0,\n" + + " \"bufferMemory\": 33554432,\n" + + " \"acks\": \"-1\",\n" + + " \"otherProperties\": {},\n" + + " \"addMetadataKeyValuesAsKafkaHeaders\": false,\n" + + " \"kafkaHeadersCharset\": \"UTF-8\"\n" + + "}" + ) + ); + } + + @Override + protected TbNode getTestNode() { + return node; + } } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/math/TbMathNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/math/TbMathNodeTest.java index d094035d3b..71e9ed8350 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/math/TbMathNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/math/TbMathNodeTest.java @@ -533,7 +533,7 @@ public class TbMathNodeTest { verify(ctx, timeout(TIMEOUT)).tellSuccess(msgCaptor.capture()); verify(telemetryService, times(1)).saveTimeseries(assertArg(request -> { assertThat(request.getEntries()).size().isOne(); - assertThat(request.getStrategy()).isEqualTo(TimeseriesSaveRequest.Strategy.SAVE_ALL); + assertThat(request.getStrategy()).isEqualTo(TimeseriesSaveRequest.Strategy.PROCESS_ALL); })); TbMsg resultMsg = msgCaptor.getValue(); @@ -569,7 +569,7 @@ public class TbMathNodeTest { verify(ctx, timeout(TIMEOUT)).tellSuccess(msgCaptor.capture()); verify(telemetryService, times(1)).saveTimeseries(assertArg(request -> { assertThat(request.getEntries()).size().isOne(); - assertThat(request.getStrategy()).isEqualTo(TimeseriesSaveRequest.Strategy.SAVE_ALL); + assertThat(request.getStrategy()).isEqualTo(TimeseriesSaveRequest.Strategy.PROCESS_ALL); })); TbMsg resultMsg = msgCaptor.getValue(); diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeTest.java index a684d23b73..941dfb92d7 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeTest.java @@ -15,13 +15,15 @@ */ package org.thingsboard.rule.engine.telemetry; -import com.fasterxml.jackson.databind.node.ObjectNode; -import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; @@ -29,6 +31,7 @@ import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; +import org.thingsboard.rule.engine.telemetry.strategy.ProcessingStrategy; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; @@ -42,185 +45,660 @@ import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.service.ConstraintValidator; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.stream.Stream; +import static com.google.common.util.concurrent.Futures.immediateFuture; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.assertArg; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.willCallRealMethod; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.thingsboard.rule.engine.api.AttributesSaveRequest.Strategy; +import static org.thingsboard.rule.engine.api.AttributesSaveRequest.builder; +import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.Advanced; +import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.Deduplicate; +import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.OnEveryMessage; +import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.WebSocketsOnly; import static org.thingsboard.server.common.data.DataConstants.NOTIFY_DEVICE_METADATA_KEY; -@Slf4j +@ExtendWith(MockitoExtension.class) class TbMsgAttributesNodeTest extends AbstractRuleNodeUpgradeTest { - private TenantId tenantId; - private DeviceId deviceId; - private TbMsgAttributesNode node; + final TenantId tenantId = TenantId.fromUUID(UUID.fromString("6c18691e-4470-4766-9739-aface71d761f")); + final DeviceId deviceId = new DeviceId(UUID.fromString("b66159d7-c77e-45e8-bb41-a8f557f434c1")); + + @Spy + TbMsgAttributesNode node; + TbMsgAttributesNodeConfiguration config; + + @Mock + TbContext ctxMock; + @Mock + AttributesService attributesServiceMock; + @Mock + RuleEngineTelemetryService telemetryServiceMock; @BeforeEach void setUp() { - tenantId = new TenantId(UUID.fromString("6c18691e-4470-4766-9739-aface71d761f")); - deviceId = new DeviceId(UUID.fromString("b66159d7-c77e-45e8-bb41-a8f557f434c1")); - node = spy(TbMsgAttributesNode.class); + lenient().when(ctxMock.getTenantId()).thenReturn(tenantId); + lenient().when(ctxMock.getAttributesService()).thenReturn(attributesServiceMock); + lenient().when(ctxMock.getTelemetryService()).thenReturn(telemetryServiceMock); + + config = new TbMsgAttributesNodeConfiguration().defaultConfiguration(); + } + + @Test + void verifyDefaultConfig() { + assertThat(config.getProcessingSettings()).isInstanceOf(OnEveryMessage.class); + assertThat(config.getScope()).isEqualTo("SERVER_SCOPE"); + assertThat(config.isNotifyDevice()).isFalse(); + assertThat(config.isSendAttributesUpdatedNotification()).isFalse(); + assertThat(config.isUpdateAttributesOnlyOnValueChange()).isTrue(); } @Test - void testFilterChangedAttr_whenCurrentAttributesEmpty_thenReturnNewAttributes() { - List newAttributes = new ArrayList<>(); + void givenProcessingSettingsAreNull_whenValidatingConstraints_thenThrowsException() { + // GIVEN + config.setProcessingSettings(null); - List filtered = node.filterChangedAttr(Collections.emptyList(), newAttributes); - assertThat(filtered).isSameAs(newAttributes); + // WHEN-THEN + assertThatThrownBy(() -> ConstraintValidator.validateFields(config)) + .isInstanceOf(DataValidationException.class) + .hasMessage("Validation error: processingSettings must not be null"); } @Test - void testFilterChangedAttr_whenCurrentAttributesContainsInAnyOrderNewAttributes_thenReturnEmptyList() { - List currentAttributes = List.of( - new BaseAttributeKvEntry(1694000000L, new StringDataEntry("address", "Peremohy ave 1")), - new BaseAttributeKvEntry(1694000000L, new BooleanDataEntry("valid", true)), - new BaseAttributeKvEntry(1694000000L, new LongDataEntry("counter", 100L)), - new BaseAttributeKvEntry(1694000000L, new DoubleDataEntry("temp", -18.35)), - new BaseAttributeKvEntry(1694000000L, new JsonDataEntry("json", "{\"warning\":\"out of paper\"}")) - ); - List newAttributes = new ArrayList<>(currentAttributes); - newAttributes.add(newAttributes.get(0)); - newAttributes.remove(0); - assertThat(newAttributes).hasSize(currentAttributes.size()); - assertThat(currentAttributes).isNotEmpty(); - assertThat(newAttributes).containsExactlyInAnyOrderElementsOf(currentAttributes); - - List filtered = node.filterChangedAttr(currentAttributes, newAttributes); - assertThat(filtered).isEmpty(); //no changes + void givenOnEveryMessageProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenPersistSameMessageTwoTimes() throws TbNodeException { + // GIVEN + config.setUpdateAttributesOnlyOnValueChange(false); + config.setProcessingSettings(new OnEveryMessage()); + + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of(NOTIFY_DEVICE_METADATA_KEY, "false"))) + .build(); + + // WHEN-THEN + var expectedSaveRequest = builder() + .tenantId(tenantId) + .entityId(msg.getOriginator()) + .scope(AttributeScope.valueOf(config.getScope())) + .entry(new DoubleDataEntry("temperature", 22.3)) + .notifyDevice(false) + .strategy(Strategy.PROCESS_ALL) + .previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds()) + .tbMsgId(msg.getId()) + .tbMsgType(msg.getInternalType()) + .build(); + + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should(times(1)).saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest) + .usingRecursiveComparison() + .ignoringFields("callback", "entries.lastUpdateTs") + .isEqualTo(expectedSaveRequest) + )); + + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should(times(2)).saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest) + .usingRecursiveComparison() + .ignoringFields("callback", "entries.lastUpdateTs") + .isEqualTo(expectedSaveRequest) + )); + } + + @Test + void givenDeduplicateProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenPersistThisMessageOnlyFirstTime() throws TbNodeException { + // GIVEN + config.setUpdateAttributesOnlyOnValueChange(false); + config.setProcessingSettings(new Deduplicate(10)); + + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of(NOTIFY_DEVICE_METADATA_KEY, "false"))) + .build(); + + // WHEN-THEN + var expectedSaveRequest = builder() + .tenantId(tenantId) + .entityId(msg.getOriginator()) + .scope(AttributeScope.valueOf(config.getScope())) + .entry(new DoubleDataEntry("temperature", 22.3)) + .notifyDevice(false) + .strategy(Strategy.PROCESS_ALL) + .previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds()) + .tbMsgId(msg.getId()) + .tbMsgType(msg.getInternalType()) + .build(); + + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should().saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest) + .usingRecursiveComparison() + .ignoringFields("callback", "entries.lastUpdateTs") + .isEqualTo(expectedSaveRequest) + )); + + clearInvocations(telemetryServiceMock, ctxMock); + + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should(never()).saveAttributes(any()); + } + + @Test + void givenWebSocketsOnlyProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenSendsOnlyWsUpdateTwoTimes() throws TbNodeException { + // GIVEN + config.setUpdateAttributesOnlyOnValueChange(false); + config.setProcessingSettings(new WebSocketsOnly()); + + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of(NOTIFY_DEVICE_METADATA_KEY, "false"))) + .build(); + + // WHEN-THEN + var expectedSaveRequest = builder() + .tenantId(tenantId) + .entityId(msg.getOriginator()) + .scope(AttributeScope.valueOf(config.getScope())) + .entry(new DoubleDataEntry("temperature", 22.3)) + .notifyDevice(false) + .strategy(Strategy.WS_ONLY) + .previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds()) + .tbMsgId(msg.getId()) + .tbMsgType(msg.getInternalType()) + .build(); + + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should(times(1)).saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest) + .usingRecursiveComparison() + .ignoringFields("callback", "entries.lastUpdateTs") + .isEqualTo(expectedSaveRequest) + )); + + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should(times(2)).saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest) + .usingRecursiveComparison() + .ignoringFields("callback", "entries.lastUpdateTs") + .isEqualTo(expectedSaveRequest) + )); + } + + @Test + void givenAdvancedProcessingSettingsWithOnEveryMessageStrategiesForAllActionsAndSameMessageTwoTimes_whenOnMsg_thenPersistSameMessageTwoTimes() throws TbNodeException { + // GIVEN + config.setUpdateAttributesOnlyOnValueChange(false); + config.setProcessingSettings(new Advanced( + ProcessingStrategy.onEveryMessage(), + ProcessingStrategy.onEveryMessage(), + ProcessingStrategy.onEveryMessage() + )); + + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of(NOTIFY_DEVICE_METADATA_KEY, "false"))) + .build(); + + // WHEN-THEN + var expectedSaveRequest = builder() + .tenantId(tenantId) + .entityId(msg.getOriginator()) + .scope(AttributeScope.valueOf(config.getScope())) + .entry(new DoubleDataEntry("temperature", 22.3)) + .notifyDevice(false) + .strategy(Strategy.PROCESS_ALL) + .previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds()) + .tbMsgId(msg.getId()) + .tbMsgType(msg.getInternalType()) + .build(); + + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should(times(1)).saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest) + .usingRecursiveComparison() + .ignoringFields("callback", "entries.lastUpdateTs") + .isEqualTo(expectedSaveRequest) + )); + + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should(times(2)).saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest) + .usingRecursiveComparison() + .ignoringFields("callback", "entries.lastUpdateTs") + .isEqualTo(expectedSaveRequest) + )); + } + + @Test + void givenAdvancedProcessingSettingsWithDifferentDeduplicateStrategyForEachAction_whenOnMsg_thenEvaluatesStrategiesForEachActionsIndependently() throws TbNodeException { + // GIVEN + config.setUpdateAttributesOnlyOnValueChange(false); + config.setProcessingSettings(new Advanced( + ProcessingStrategy.deduplicate(1), + ProcessingStrategy.deduplicate(2), + ProcessingStrategy.deduplicate(3) + )); + + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + long ts1 = 500L; + long ts2 = 1500L; + long ts3 = 2500L; + long ts4 = 3500L; + + // WHEN-THEN + node.onMsg(ctxMock, TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of("ts", Long.toString(ts1)))) + .build()); + then(telemetryServiceMock).should().saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo(Strategy.PROCESS_ALL) + )); + + clearInvocations(telemetryServiceMock); + + node.onMsg(ctxMock, TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of("ts", Long.toString(ts2)))) + .build()); + then(telemetryServiceMock).should().saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo(new Strategy(true, false, false)) + )); + + clearInvocations(telemetryServiceMock); + + node.onMsg(ctxMock, TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of("ts", Long.toString(ts3)))) + .build()); + then(telemetryServiceMock).should().saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo(new Strategy(true, true, false)) + )); + + clearInvocations(telemetryServiceMock); + + node.onMsg(ctxMock, TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of("ts", Long.toString(ts4)))) + .build()); + then(telemetryServiceMock).should().saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo(new Strategy(true, false, true)) + )); } @Test - void testFilterChangedAttr_whenCurrentAttributesContainsInAnyOrderNewAttributes_thenReturnExpectedList() { + public void givenAdvancedProcessingSettingsWithSkipStrategiesForAllActionsAndSameMessageTwoTimes_whenOnMsg_thenSkipsSameMessageTwoTimes() throws TbNodeException { + // GIVEN + config.setProcessingSettings(new Advanced( + ProcessingStrategy.skip(), + ProcessingStrategy.skip(), + ProcessingStrategy.skip() + )); + + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of(NOTIFY_DEVICE_METADATA_KEY, "false"))) + .build(); + + // WHEN-THEN + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should(never()).saveAttributes(any()); + then(ctxMock).should(times(1)).tellSuccess(msg); + + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should(never()).saveAttributes(any()); + then(ctxMock).should(times(2)).tellSuccess(msg); + } + + @Test + void givenVariousChangesToAttributes_whenUpdateOnlyOnValueChangeEnabled_thenShouldCorrectlyFilterChangedAttributes() throws TbNodeException { + // GIVEN + config.setUpdateAttributesOnlyOnValueChange(true); + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + List currentAttributes = List.of( - new BaseAttributeKvEntry(1694000000L, new StringDataEntry("address", "Peremohy ave 1")), - new BaseAttributeKvEntry(1694000000L, new BooleanDataEntry("valid", true)), - new BaseAttributeKvEntry(1694000000L, new LongDataEntry("counter", 100L)), - new BaseAttributeKvEntry(1694000000L, new DoubleDataEntry("temp", -18.35)), - new BaseAttributeKvEntry(1694000000L, new JsonDataEntry("json", "{\"warning\":\"out of paper\"}")) + new BaseAttributeKvEntry(123L, new StringDataEntry("address", "Prospect Beresteiskyi 1")), + new BaseAttributeKvEntry(123L, new BooleanDataEntry("valid", true)), + new BaseAttributeKvEntry(123L, new LongDataEntry("counter", 100L)), + new BaseAttributeKvEntry(123L, new DoubleDataEntry("temp", -18.35)), + new BaseAttributeKvEntry(123L, new JsonDataEntry("json", "{\"warning\":\"out of paper\"}")) ); - List newAttributes = List.of( - new BaseAttributeKvEntry(1694000999L, new JsonDataEntry("json", "{\"status\":\"OK\"}")), // value changed, reordered - new BaseAttributeKvEntry(1694000999L, new StringDataEntry("valid", "true")), //type changed - new BaseAttributeKvEntry(1694000999L, new LongDataEntry("counter", 101L)), //value changed - new BaseAttributeKvEntry(1694000999L, new DoubleDataEntry("temp", -18.35)), - new BaseAttributeKvEntry(1694000999L, new StringDataEntry("address", "Peremohy ave 1")) // reordered - ); - List expected = List.of( - new BaseAttributeKvEntry(1694000999L, new StringDataEntry("valid", "true")), - new BaseAttributeKvEntry(1694000999L, new LongDataEntry("counter", 101L)), - new BaseAttributeKvEntry(1694000999L, new JsonDataEntry("json", "{\"status\":\"OK\"}")) + given(attributesServiceMock.find(eq(tenantId), eq(deviceId), eq(AttributeScope.valueOf(config.getScope())), anyList())).willReturn(immediateFuture(currentAttributes)); + + var data = JacksonUtil.newObjectNode() + .put("address", "Prospect Beresteiskyi 1") // no changes + .put("valid", "false") // type and value changed + .put("counter", 101L) // value changed + .put("temp", -18.35) // no changes + .put("json", "{\"warning\":\"out of paper\"}") // only type changed + .put("newKey", "newValue"); // new attribute + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(data.toString()) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + node.onMsg(ctxMock, msg); + + // THEN + List expectedChangedAttributes = List.of( + new BaseAttributeKvEntry(456L, new StringDataEntry("valid", "false")), + new BaseAttributeKvEntry(456L, new LongDataEntry("counter", 101L)), + new BaseAttributeKvEntry(456L, new StringDataEntry("json", "{\"warning\":\"out of paper\"}")), + new BaseAttributeKvEntry(456L, new StringDataEntry("newKey", "newValue")) ); - List filtered = node.filterChangedAttr(currentAttributes, newAttributes); - assertThat(filtered).containsExactlyInAnyOrderElementsOf(expected); + then(telemetryServiceMock).should().saveAttributes(assertArg(request -> + assertThat(request.getEntries()) + .usingRecursiveComparison() + .ignoringCollectionOrder() + .ignoringFields("lastUpdateTs") + .isEqualTo(expectedChangedAttributes) + )); } - // Notify device backward-compatibility test arguments - private static Stream givenNotifyDeviceMdValue_whenSaveAndNotify_thenVerifyExpectedArgumentForNotifyDeviceInSaveAndNotifyMethod() { - return Stream.of( - Arguments.of(null, true), - Arguments.of("null", false), - Arguments.of("true", true), - Arguments.of("false", false) + @Test + void givenNoChangesToAttributes_whenUpdateOnlyOnValueChangeEnabled_thenShouldNotCallSaveAndJustTellSuccess() throws TbNodeException { + // GIVEN + config.setUpdateAttributesOnlyOnValueChange(true); + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + List currentAttributes = List.of( + new BaseAttributeKvEntry(123L, new StringDataEntry("address", "Prospect Beresteiskyi 1")), + new BaseAttributeKvEntry(123L, new BooleanDataEntry("valid", true)), + new BaseAttributeKvEntry(123L, new LongDataEntry("counter", 100L)) ); + given(attributesServiceMock.find(eq(tenantId), eq(deviceId), eq(AttributeScope.valueOf(config.getScope())), anyList())).willReturn(immediateFuture(currentAttributes)); + + var data = JacksonUtil.newObjectNode() + .put("address", "Prospect Beresteiskyi 1") + .put("valid", true) + .put("counter", 100L); + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(data.toString()) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + node.onMsg(ctxMock, msg); + + // THEN + then(telemetryServiceMock).shouldHaveNoInteractions(); + then(ctxMock).should().tellSuccess(msg); } // Notify device backward-compatibility test @ParameterizedTest @MethodSource - void givenNotifyDeviceMdValue_whenSaveAndNotify_thenVerifyExpectedArgumentForNotifyDeviceInSaveAndNotifyMethod(String mdValue, boolean expectedArgumentValue) throws TbNodeException { - var ctxMock = mock(TbContext.class); - var telemetryServiceMock = mock(RuleEngineTelemetryService.class); - ObjectNode defaultConfig = (ObjectNode) JacksonUtil.valueToTree(new TbMsgAttributesNodeConfiguration().defaultConfiguration()); - defaultConfig.put("notifyDevice", false); - var tbNodeConfiguration = new TbNodeConfiguration(defaultConfig); - - assertThat(defaultConfig.has("notifyDevice")).as("pre condition has notifyDevice").isTrue(); - - when(ctxMock.getTenantId()).thenReturn(tenantId); - when(ctxMock.getTelemetryService()).thenReturn(telemetryServiceMock); - willCallRealMethod().given(node).init(any(TbContext.class), any(TbNodeConfiguration.class)); - willCallRealMethod().given(node).saveAttr(any(), eq(ctxMock), any(TbMsg.class), any(AttributeScope.class), anyBoolean()); - - node.init(ctxMock, tbNodeConfiguration); - - TbMsgMetaData md = new TbMsgMetaData(); - if (mdValue != null) { - md.putValue(NOTIFY_DEVICE_METADATA_KEY, mdValue); - } - // dummy list with one ts kv to pass the empty list check. - var testTbMsg = TbMsg.newMsg() - .type(TbMsgType.POST_TELEMETRY_REQUEST) + void givenVariousValuesForNotifyDeviceInMetadata_thenShouldCorrectlyParseValueFromMetadata(String mdValue, boolean expectedArgumentValue) throws TbNodeException { + // GIVEN + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + given(attributesServiceMock.find(tenantId, deviceId, AttributeScope.valueOf(config.getScope()), List.of("mode"))).willReturn( + immediateFuture(List.of(new BaseAttributeKvEntry(123L, new StringDataEntry("mode", "tilt")))) + ); + + var metadata = new TbMsgMetaData(); + metadata.putValue(NOTIFY_DEVICE_METADATA_KEY, mdValue); + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) .originator(deviceId) - .copyMetaData(md) - .data(TbMsg.EMPTY_STRING) + .data(JacksonUtil.newObjectNode().put("mode", "vibration").toString()) + .metaData(metadata) .build(); - List testAttrList = List.of(new BaseAttributeKvEntry(0L, new StringDataEntry("testKey", "testValue"))); - node.saveAttr(testAttrList, ctxMock, testTbMsg, AttributeScope.SHARED_SCOPE, false); + // WHEN + node.onMsg(ctxMock, msg); + + // THEN + then(telemetryServiceMock).should().saveAttributes(assertArg(request -> assertThat(request.isNotifyDevice()).isEqualTo(expectedArgumentValue))); + } - verify(telemetryServiceMock, times(1)).saveAttributes(assertArg(request -> { - assertThat(request.getTenantId()).isEqualTo(tenantId); - assertThat(request.getEntityId()).isEqualTo(deviceId); - assertThat(request.getScope()).isEqualTo(AttributeScope.SHARED_SCOPE); - assertThat(request.getEntries()).isEqualTo(testAttrList); - assertThat(request.isNotifyDevice()).isEqualTo(expectedArgumentValue); - })); + // Notify device backward-compatibility test arguments + static Stream givenVariousValuesForNotifyDeviceInMetadata_thenShouldCorrectlyParseValueFromMetadata() { + return Stream.of( + Arguments.of(null, true), + Arguments.of("null", false), + Arguments.of("true", true), + Arguments.of("false", false) + ); } // Rule nodes upgrade - private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { + static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { return Stream.of( // default config for version 0 Arguments.of(0, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":\"false\",\"sendAttributesUpdatedNotification\":\"false\"}", + """ + { + "scope": "CLIENT_SCOPE", + "notifyDevice": "false", + "sendAttributesUpdatedNotification": "false" + } + """, true, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":false,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":false}"), + """ + { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, + "scope": "CLIENT_SCOPE", + "notifyDevice": false, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": false + } + """ + ), // default config for version 1 with upgrade from version 0 Arguments.of(0, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":false,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}", - false, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":false,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}"), + """ + { + "scope": "CLIENT_SCOPE", + "notifyDevice": false, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """, + true, + """ + { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, + "scope": "CLIENT_SCOPE", + "notifyDevice": false, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """ + ), // all flags are booleans Arguments.of(1, - "{\"scope\":\"SHARED_SCOPE\",\"notifyDevice\":true,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}", - false, - "{\"scope\":\"SHARED_SCOPE\",\"notifyDevice\":true,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}"), + """ + { + "scope": "SHARED_SCOPE", + "notifyDevice": true, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """, + true, + """ + { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, + "scope": "SHARED_SCOPE", + "notifyDevice": true, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """ + ), // no boolean flags set Arguments.of(1, - "{\"scope\":\"CLIENT_SCOPE\"}", + """ + { + "scope": "CLIENT_SCOPE" + } + """, true, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":true,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}"), + """ + { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, + "scope": "CLIENT_SCOPE", + "notifyDevice": true, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """ + ), // all flags are boolean strings Arguments.of(1, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":\"false\",\"sendAttributesUpdatedNotification\":\"false\",\"updateAttributesOnlyOnValueChange\":\"true\"}", + """ + { + "scope": "CLIENT_SCOPE", + "notifyDevice": "false", + "sendAttributesUpdatedNotification": "false", + "updateAttributesOnlyOnValueChange": "true" + } + """, true, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":false,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}"), + """ + { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, + "scope": "CLIENT_SCOPE", + "notifyDevice": false, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """ + ), // at least one flag is boolean string Arguments.of(1, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":\"false\",\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}", + """ + { + "scope": "CLIENT_SCOPE", + "notifyDevice": "false", + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """, true, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":false,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}"), + """ + { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, + "scope": "CLIENT_SCOPE", + "notifyDevice": false, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """ + ), // notify device flag is null Arguments.of(1, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":\"null\",\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}", + """ + { + "scope": "CLIENT_SCOPE", + "notifyDevice": "null", + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """, + true, + """ + { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, + "scope": "CLIENT_SCOPE", + "notifyDevice": true, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """ + ), + // default config for version 2 + Arguments.of(2, + """ + { + "scope": "SERVER_SCOPE", + "notifyDevice": false, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """, true, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":true,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}") + """ + { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, + "scope": "SERVER_SCOPE", + "notifyDevice": false, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """ + ) ); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeTest.java index de651e9841..1141ff09d1 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeTest.java @@ -73,6 +73,10 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.Advanced; +import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.Deduplicate; +import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.OnEveryMessage; +import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.WebSocketsOnly; @ExtendWith(MockitoExtension.class) public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { @@ -110,7 +114,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { @Test public void verifyDefaultConfig() { assertThat(config.getDefaultTTL()).isEqualTo(0L); - assertThat(config.getProcessingSettings()).isInstanceOf(TbMsgTimeseriesNodeConfiguration.ProcessingSettings.OnEveryMessage.class); + assertThat(config.getProcessingSettings()).isInstanceOf(OnEveryMessage.class); assertThat(config.isUseServerTs()).isFalse(); } @@ -208,7 +212,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { assertThat(request.getEntityId()).isEqualTo(DEVICE_ID); assertThat(request.getEntries()).usingRecursiveFieldByFieldElementComparatorIgnoringFields("ts").containsExactlyElementsOf(expectedList); assertThat(request.getTtl()).isEqualTo(extractTtlAsSeconds(tenantProfile)); - assertThat(request.getStrategy()).isEqualTo(TimeseriesSaveRequest.Strategy.SAVE_ALL); + assertThat(request.getStrategy()).isEqualTo(TimeseriesSaveRequest.Strategy.PROCESS_ALL); assertThat(request.getCallback()).isInstanceOf(TelemetryNodeCallback.class); })); verify(ctxMock).tellSuccess(msg); @@ -220,10 +224,11 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { // GIVEN config.setDefaultTTL(10L); - var timeseriesStrategy = ProcessingStrategy.onEveryMessage(); - var latestStrategy = ProcessingStrategy.skip(); + var timeseries = ProcessingStrategy.onEveryMessage(); + var latest = ProcessingStrategy.skip(); var webSockets = ProcessingStrategy.onEveryMessage(); - var processingSettings = new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Advanced(timeseriesStrategy, latestStrategy, webSockets); + var calculatedFields = ProcessingStrategy.onEveryMessage(); + var processingSettings = new Advanced(timeseries, latest, webSockets, calculatedFields); config.setProcessingSettings(processingSettings); node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); @@ -265,7 +270,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { assertThat(request.getEntityId()).isEqualTo(DEVICE_ID); assertThat(request.getEntries()).containsExactlyElementsOf(expectedList); assertThat(request.getTtl()).isEqualTo(config.getDefaultTTL()); - assertThat(request.getStrategy()).isEqualTo(new TimeseriesSaveRequest.Strategy(true, false, true)); + assertThat(request.getStrategy()).isEqualTo(new TimeseriesSaveRequest.Strategy(true, false, true, true)); assertThat(request.getCallback()).isInstanceOf(TelemetryNodeCallback.class); })); verify(ctxMock).tellSuccess(msg); @@ -304,7 +309,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { assertThat(request.getCustomerId()).isNull(); assertThat(request.getEntityId()).isEqualTo(DEVICE_ID); assertThat(request.getTtl()).isEqualTo(expectedTtl); - assertThat(request.getStrategy()).isEqualTo(TimeseriesSaveRequest.Strategy.SAVE_ALL); + assertThat(request.getStrategy()).isEqualTo(TimeseriesSaveRequest.Strategy.PROCESS_ALL); assertThat(request.getCallback()).isInstanceOf(TelemetryNodeCallback.class); })); } @@ -335,7 +340,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { @Test public void givenOnEveryMessageProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenPersistSameMessageTwoTimes() throws TbNodeException { // GIVEN - config.setProcessingSettings(new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.OnEveryMessage()); + config.setProcessingSettings(new OnEveryMessage()); node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); @@ -353,7 +358,10 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { .entityId(msg.getOriginator()) .entry(new BasicTsKvEntry(123L, new DoubleDataEntry("temperature", 22.3))) .ttl(extractTtlAsSeconds(tenantProfile)) - .strategy(TimeseriesSaveRequest.Strategy.SAVE_ALL) + .strategy(TimeseriesSaveRequest.Strategy.PROCESS_ALL) + .previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds()) + .tbMsgId(msg.getId()) + .tbMsgType(msg.getInternalType()) .build(); node.onMsg(ctxMock, msg); @@ -370,7 +378,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { @Test public void givenDeduplicateProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenPersistThisMessageOnlyFirstTime() throws TbNodeException { // GIVEN - config.setProcessingSettings(new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Deduplicate(10)); + config.setProcessingSettings(new Deduplicate(10)); node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); @@ -388,7 +396,10 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { .entityId(msg.getOriginator()) .entry(new BasicTsKvEntry(123L, new DoubleDataEntry("temperature", 22.3))) .ttl(extractTtlAsSeconds(tenantProfile)) - .strategy(TimeseriesSaveRequest.Strategy.SAVE_ALL) + .strategy(TimeseriesSaveRequest.Strategy.PROCESS_ALL) + .previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds()) + .tbMsgId(msg.getId()) + .tbMsgType(msg.getInternalType()) .build(); node.onMsg(ctxMock, msg); @@ -405,7 +416,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { @Test public void givenWebSocketsOnlyProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenSendsOnlyWsUpdateTwoTimes() throws TbNodeException { // GIVEN - config.setProcessingSettings(new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.WebSocketsOnly()); + config.setProcessingSettings(new WebSocketsOnly()); node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); @@ -424,6 +435,9 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { .entry(new BasicTsKvEntry(123L, new DoubleDataEntry("temperature", 22.3))) .ttl(extractTtlAsSeconds(tenantProfile)) .strategy(TimeseriesSaveRequest.Strategy.WS_ONLY) + .previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds()) + .tbMsgId(msg.getId()) + .tbMsgType(msg.getInternalType()) .build(); node.onMsg(ctxMock, msg); @@ -440,7 +454,8 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { @Test public void givenAdvancedProcessingSettingsWithOnEveryMessageStrategiesForAllActionsAndSameMessageTwoTimes_whenOnMsg_thenPersistSameMessageTwoTimes() throws TbNodeException { // GIVEN - config.setProcessingSettings(new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Advanced( + config.setProcessingSettings(new Advanced( + ProcessingStrategy.onEveryMessage(), ProcessingStrategy.onEveryMessage(), ProcessingStrategy.onEveryMessage(), ProcessingStrategy.onEveryMessage() @@ -462,7 +477,10 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { .entityId(msg.getOriginator()) .entry(new BasicTsKvEntry(123L, new DoubleDataEntry("temperature", 22.3))) .ttl(extractTtlAsSeconds(tenantProfile)) - .strategy(TimeseriesSaveRequest.Strategy.SAVE_ALL) + .strategy(TimeseriesSaveRequest.Strategy.PROCESS_ALL) + .previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds()) + .tbMsgId(msg.getId()) + .tbMsgType(msg.getInternalType()) .build(); node.onMsg(ctxMock, msg); @@ -479,10 +497,11 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { @Test public void givenAdvancedProcessingSettingsWithDifferentDeduplicateStrategyForEachAction_whenOnMsg_thenEvaluatesStrategiesForEachActionsIndependently() throws TbNodeException { // GIVEN - config.setProcessingSettings(new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Advanced( + config.setProcessingSettings(new Advanced( ProcessingStrategy.deduplicate(1), ProcessingStrategy.deduplicate(2), - ProcessingStrategy.deduplicate(3) + ProcessingStrategy.deduplicate(3), + ProcessingStrategy.deduplicate(4) )); node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); @@ -490,6 +509,8 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { long ts1 = 500L; long ts2 = 1500L; long ts3 = 2500L; + long ts4 = 3500L; + long ts5 = 4500L; // WHEN-THEN node.onMsg(ctxMock, TbMsg.newMsg() @@ -499,7 +520,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { .metaData(new TbMsgMetaData(Map.of("ts", Long.toString(ts1)))) .build()); then(telemetryServiceMock).should().saveTimeseries(assertArg( - actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo(TimeseriesSaveRequest.Strategy.SAVE_ALL) + actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo(TimeseriesSaveRequest.Strategy.PROCESS_ALL) )); clearInvocations(telemetryServiceMock); @@ -511,7 +532,9 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { .metaData(new TbMsgMetaData(Map.of("ts", Long.toString(ts2)))) .build()); then(telemetryServiceMock).should().saveTimeseries(assertArg( - actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo(new TimeseriesSaveRequest.Strategy(true, false, false)) + actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo( + new TimeseriesSaveRequest.Strategy(true, false, false, false) + ) )); clearInvocations(telemetryServiceMock); @@ -523,14 +546,45 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { .metaData(new TbMsgMetaData(Map.of("ts", Long.toString(ts3)))) .build()); then(telemetryServiceMock).should().saveTimeseries(assertArg( - actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo(new TimeseriesSaveRequest.Strategy(true, true, false)) + actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo( + new TimeseriesSaveRequest.Strategy(true, true, false, false) + ) + )); + + clearInvocations(telemetryServiceMock); + + node.onMsg(ctxMock, TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(DEVICE_ID) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of("ts", Long.toString(ts4)))) + .build()); + then(telemetryServiceMock).should().saveTimeseries(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo( + new TimeseriesSaveRequest.Strategy(true, false, true, false) + ) + )); + + clearInvocations(telemetryServiceMock); + + node.onMsg(ctxMock, TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(DEVICE_ID) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of("ts", Long.toString(ts5)))) + .build()); + then(telemetryServiceMock).should().saveTimeseries(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo( + new TimeseriesSaveRequest.Strategy(true, true, false, true) + ) )); } @Test public void givenAdvancedProcessingSettingsWithSkipStrategiesForAllActionsAndSameMessageTwoTimes_whenOnMsg_thenSkipsSameMessageTwoTimes() throws TbNodeException { // GIVEN - config.setProcessingSettings(new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Advanced( + config.setProcessingSettings(new Advanced( + ProcessingStrategy.skip(), ProcessingStrategy.skip(), ProcessingStrategy.skip(), ProcessingStrategy.skip() @@ -631,6 +685,9 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { }, "webSockets": { "type": "ON_EVERY_MESSAGE" + }, + "calculatedFields": { + "type": "ON_EVERY_MESSAGE" } } }""") diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java index 8bcb8503d8..38417c3922 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java @@ -43,6 +43,8 @@ import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; import org.thingsboard.server.common.data.domain.Domain; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.AssetProfileId; @@ -67,6 +69,7 @@ import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetsBundle; import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.DeviceService; @@ -155,6 +158,8 @@ public class TenantIdLoaderTest { private MobileAppService mobileAppService; @Mock private MobileAppBundleService mobileAppBundleService; + @Mock + private CalculatedFieldService calculatedFieldService; private TenantId tenantId; private TenantProfileId tenantProfileId; @@ -402,6 +407,18 @@ public class TenantIdLoaderTest { when(ctx.getMobileAppBundleService()).thenReturn(mobileAppBundleService); doReturn(mobileAppBundle).when(mobileAppBundleService).findMobileAppBundleById(eq(tenantId), any()); break; + case CALCULATED_FIELD: + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setTenantId(tenantId); + when(ctx.getCalculatedFieldService()).thenReturn(calculatedFieldService); + doReturn(calculatedField).when(calculatedFieldService).findById(eq(tenantId), any()); + break; + case CALCULATED_FIELD_LINK: + CalculatedFieldLink calculatedFieldLink = new CalculatedFieldLink(); + calculatedFieldLink.setTenantId(tenantId); + when(ctx.getCalculatedFieldService()).thenReturn(calculatedFieldService); + doReturn(calculatedFieldLink).when(calculatedFieldService).findCalculatedFieldLinkById(eq(tenantId), any()); + break; default: throw new RuntimeException("Unexpected originator EntityType " + entityType); } diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index f1d325b81c..f60a6bd47e 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -347,6 +347,8 @@ queue: core: # Default topic name topic: "${TB_QUEUE_CORE_TOPIC:tb_core}" + # For high-priority notifications that require minimum latency and processing time + notifications_topic: "${TB_QUEUE_CORE_NOTIFICATIONS_TOPIC:tb_core.notifications}" # Interval in milliseconds to poll messages by Core microservices poll-interval: "${TB_QUEUE_CORE_POLL_INTERVAL_MS:25}" # Amount of partitions used by Core microservices @@ -377,6 +379,8 @@ queue: rule-engine: # Deprecated. It will be removed in the nearest releases topic: "${TB_QUEUE_RULE_ENGINE_TOPIC:tb_rule_engine}" + # For high-priority notifications that require minimum latency and processing time + notifications_topic: "${TB_QUEUE_RULE_ENGINE_NOTIFICATIONS_TOPIC:tb_rule_engine.notifications}" # Interval in milliseconds to poll messages by Rule Engine poll-interval: "${TB_QUEUE_RULE_ENGINE_POLL_INTERVAL_MS:25}" # Timeout for processing a message pack of Rule Engine @@ -407,7 +411,9 @@ usage: # Enable/Disable collection of statistics about API usage on a customer level enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Interval of reporting the statistics. By default, the summarized statistics are sent every 10 seconds - interval: "${USAGE_STATS_REPORT_INTERVAL:10}" + interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Amount of statistic messages in pack + pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" # Metrics parameters metrics: diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index eca40b19fb..c921f9f9ae 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -296,6 +296,8 @@ queue: core: # Default topic name topic: "${TB_QUEUE_CORE_TOPIC:tb_core}" + # For high-priority notifications that require minimum latency and processing time + notifications_topic: "${TB_QUEUE_CORE_NOTIFICATIONS_TOPIC:tb_core.notifications}" # Interval in milliseconds to poll messages by Core microservices poll-interval: "${TB_QUEUE_CORE_POLL_INTERVAL_MS:25}" # Amount of partitions used by Core microservices @@ -326,6 +328,8 @@ queue: rule-engine: # Deprecated. It will be removed in the nearest releases topic: "${TB_QUEUE_RULE_ENGINE_TOPIC:tb_rule_engine}" + # For high-priority notifications that require minimum latency and processing time + notifications_topic: "${TB_QUEUE_RULE_ENGINE_NOTIFICATIONS_TOPIC:tb_rule_engine.notifications}" # Interval in milliseconds to poll messages by Rule Engine poll-interval: "${TB_QUEUE_RULE_ENGINE_POLL_INTERVAL_MS:25}" # Timeout for processing a message pack of Rule Engine @@ -356,7 +360,9 @@ usage: # Enable/Disable collection of statistics about API usage on a customer level enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Interval of reporting the statistics. By default, the summarized statistics are sent every 10 seconds - interval: "${USAGE_STATS_REPORT_INTERVAL:10}" + interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Amount of statistic messages in pack + pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" # Metrics parameters metrics: diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index 149fc2a6e2..85e865e60e 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -397,6 +397,8 @@ queue: core: # Default topic name topic: "${TB_QUEUE_CORE_TOPIC:tb_core}" + # For high-priority notifications that require minimum latency and processing time + notifications_topic: "${TB_QUEUE_CORE_NOTIFICATIONS_TOPIC:tb_core.notifications}" # Interval in milliseconds to poll messages by Core microservices poll-interval: "${TB_QUEUE_CORE_POLL_INTERVAL_MS:25}" # Amount of partitions used by Core microservices @@ -427,6 +429,8 @@ queue: rule-engine: # Deprecated. It will be removed in the nearest releases topic: "${TB_QUEUE_RULE_ENGINE_TOPIC:tb_rule_engine}" + # For high-priority notifications that require minimum latency and processing time + notifications_topic: "${TB_QUEUE_RULE_ENGINE_NOTIFICATIONS_TOPIC:tb_rule_engine.notifications}" # Interval in milliseconds to poll messages by Rule Engine poll-interval: "${TB_QUEUE_RULE_ENGINE_POLL_INTERVAL_MS:25}" # Timeout for processing a message pack of Rule Engine @@ -457,7 +461,9 @@ usage: # Enable/Disable collection of statistics about API usage on a customer level enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Interval of reporting the statistics. By default, the summarized statistics are sent every 10 seconds - interval: "${USAGE_STATS_REPORT_INTERVAL:10}" + interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Amount of statistic messages in pack + pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" # Metrics parameters metrics: diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index b35ce5e7be..a6ca2f1a6e 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -330,6 +330,8 @@ queue: core: # Default topic name topic: "${TB_QUEUE_CORE_TOPIC:tb_core}" + # For high-priority notifications that require minimum latency and processing time + notifications_topic: "${TB_QUEUE_CORE_NOTIFICATIONS_TOPIC:tb_core.notifications}" # Interval in milliseconds to poll messages by Core microservices poll-interval: "${TB_QUEUE_CORE_POLL_INTERVAL_MS:25}" # Amount of partitions used by Core microservices @@ -360,6 +362,8 @@ queue: rule-engine: # Deprecated. It will be removed in the nearest releases topic: "${TB_QUEUE_RULE_ENGINE_TOPIC:tb_rule_engine}" + # For high-priority notifications that require minimum latency and processing time + notifications_topic: "${TB_QUEUE_RULE_ENGINE_NOTIFICATIONS_TOPIC:tb_rule_engine.notifications}" # Interval in milliseconds to poll messages by Rule Engine poll-interval: "${TB_QUEUE_RULE_ENGINE_POLL_INTERVAL_MS:25}" # Timeout for processing a message pack of Rule Engine @@ -390,7 +394,9 @@ usage: # Enable/Disable collection of statistics about API usage on a customer level enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Interval of reporting the statistics. By default, the summarized statistics are sent every 10 seconds - interval: "${USAGE_STATS_REPORT_INTERVAL:10}" + interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Amount of statistic messages in pack + pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" # Metrics parameters metrics: diff --git a/transport/snmp/src/main/resources/tb-snmp-transport.yml b/transport/snmp/src/main/resources/tb-snmp-transport.yml index 4de8a0e2c5..6848e8af26 100644 --- a/transport/snmp/src/main/resources/tb-snmp-transport.yml +++ b/transport/snmp/src/main/resources/tb-snmp-transport.yml @@ -283,6 +283,8 @@ queue: core: # Default topic name topic: "${TB_QUEUE_CORE_TOPIC:tb_core}" + # For high-priority notifications that require minimum latency and processing time + notifications_topic: "${TB_QUEUE_CORE_NOTIFICATIONS_TOPIC:tb_core.notifications}" # Interval in milliseconds to poll messages by Core microservices poll-interval: "${TB_QUEUE_CORE_POLL_INTERVAL_MS:25}" # Amount of partitions used by Core microservices @@ -313,6 +315,8 @@ queue: rule-engine: # Deprecated. It will be removed in the nearest releases topic: "${TB_QUEUE_RULE_ENGINE_TOPIC:tb_rule_engine}" + # For high-priority notifications that require minimum latency and processing time + notifications_topic: "${TB_QUEUE_RULE_ENGINE_NOTIFICATIONS_TOPIC:tb_rule_engine.notifications}" # Interval in milliseconds to poll messages by Rule Engine poll-interval: "${TB_QUEUE_RULE_ENGINE_POLL_INTERVAL_MS:25}" # Timeout for processing a message pack of Rule Engine @@ -343,7 +347,9 @@ usage: # Enable/Disable collection of statistics about API usage on a customer level enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Interval of reporting the statistics. By default, the summarized statistics are sent every 10 seconds - interval: "${USAGE_STATS_REPORT_INTERVAL:10}" + interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Amount of statistic messages in pack + pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" # Metrics parameters metrics: diff --git a/ui-ngx/angular.json b/ui-ngx/angular.json index 8f98a6cf07..dbac347096 100644 --- a/ui-ngx/angular.json +++ b/ui-ngx/angular.json @@ -101,7 +101,8 @@ "node_modules/tooltipster/dist/css/plugins/tooltipster/sideTip/themes/tooltipster-sideTip-shadow.min.css", "node_modules/jstree-bootstrap-theme/dist/themes/proton/style.min.css", "node_modules/leaflet/dist/leaflet.css", - "src/app/modules/home/components/widget/lib/maps/markers.scss", + "src/app/modules/home/components/widget/lib/maps/map.scss", + "src/app/modules/home/components/widget/lib/maps-legacy/markers.scss", "src/app/modules/home/components/widget/lib/home-page/home-page.scss", "node_modules/leaflet.markercluster/dist/MarkerCluster.css", "node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css", diff --git a/ui-ngx/package.json b/ui-ngx/package.json index f62c991187..1a32c7d41c 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -18,7 +18,6 @@ "@angular/common": "18.2.13", "@angular/compiler": "18.2.13", "@angular/core": "18.2.13", - "@angular/flex-layout": "^15.0.0-beta.42", "@angular/forms": "18.2.13", "@angular/material": "18.2.14", "@angular/platform-browser": "18.2.13", @@ -62,9 +61,10 @@ "leaflet": "1.9.4", "leaflet-polylinedecorator": "1.6.0", "leaflet-providers": "2.0.0", - "leaflet.gridlayer.googlemutant": "0.14.1", + "leaflet.gridlayer.googlemutant": "0.15.0", "leaflet.markercluster": "1.5.3", "libphonenumber-js": "^1.11.15", + "maplibre-gl": "^5.2.0", "marked": "~12.0.2", "moment": "^2.30.1", "moment-timezone": "^0.5.45", diff --git a/ui-ngx/patches/@angular+flex-layout+15.0.0-beta.42.patch b/ui-ngx/patches/@angular+flex-layout+15.0.0-beta.42.patch deleted file mode 100644 index d4bf9d42cd..0000000000 --- a/ui-ngx/patches/@angular+flex-layout+15.0.0-beta.42.patch +++ /dev/null @@ -1,21 +0,0 @@ -diff --git a/node_modules/@angular/flex-layout/fesm2020/angular-flex-layout-extended.mjs b/node_modules/@angular/flex-layout/fesm2020/angular-flex-layout-extended.mjs -index 735dff5..52b6392 100644 ---- a/node_modules/@angular/flex-layout/fesm2020/angular-flex-layout-extended.mjs -+++ b/node_modules/@angular/flex-layout/fesm2020/angular-flex-layout-extended.mjs -@@ -124,7 +124,7 @@ class ClassDirective extends BaseDirective2 { - if (!this.ngClassInstance) { - // Create an instance NgClass Directive instance only if `ngClass=""` has NOT been defined on - // the same host element; since the responsive variations may be defined... -- this.ngClassInstance = new NgClass(iterableDiffers, keyValueDiffers, elementRef, renderer2); -+ this.ngClassInstance = new NgClass(elementRef, renderer2); - } - this.init(); - this.setValue('', ''); -diff --git a/node_modules/@angular/flex-layout/fesm2020/angular-flex-layout-extended.mjs.map b/node_modules/@angular/flex-layout/fesm2020/angular-flex-layout-extended.mjs.map -index a184784..2d0682e 100644 ---- a/node_modules/@angular/flex-layout/fesm2020/angular-flex-layout-extended.mjs.map -+++ b/node_modules/@angular/flex-layout/fesm2020/angular-flex-layout-extended.mjs.map -@@ -1 +1 @@ --{"version":3,"file":"angular-flex-layout-extended.mjs","sources":["../../../../projects/libs/flex-layout/extended/img-src/img-src.ts","../../../../projects/libs/flex-layout/extended/class/class.ts","../../../../projects/libs/flex-layout/extended/show-hide/show-hide.ts","../../../../projects/libs/flex-layout/extended/style/style-transforms.ts","../../../../projects/libs/flex-layout/extended/style/style.ts","../../../../projects/libs/flex-layout/extended/module.ts","../../../../projects/libs/flex-layout/extended/public-api.ts","../../../../projects/libs/flex-layout/extended/angular-flex-layout-extended.ts"],"sourcesContent":["/**\n * @license\n * Copyright Google LLC All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\nimport {Directive, ElementRef, Inject, PLATFORM_ID, Injectable, Input} from '@angular/core';\nimport {isPlatformServer} from '@angular/common';\nimport {\n MediaMarshaller,\n BaseDirective2,\n SERVER_TOKEN,\n StyleBuilder,\n StyleDefinition,\n StyleUtils,\n} from '@angular/flex-layout/core';\n\n@Injectable({providedIn: 'root'})\nexport class ImgSrcStyleBuilder extends StyleBuilder {\n buildStyles(url: string) {\n return {'content': url ? `url(${url})` : ''};\n }\n}\n\n@Directive()\nexport class ImgSrcDirective extends BaseDirective2 {\n protected override DIRECTIVE_KEY = 'img-src';\n protected defaultSrc = '';\n\n @Input('src')\n set src(val: string) {\n this.defaultSrc = val;\n this.setValue(this.defaultSrc, '');\n }\n\n constructor(elementRef: ElementRef,\n styleBuilder: ImgSrcStyleBuilder,\n styler: StyleUtils,\n marshal: MediaMarshaller,\n @Inject(PLATFORM_ID) protected platformId: Object,\n @Inject(SERVER_TOKEN) protected serverModuleLoaded: boolean) {\n super(elementRef, styleBuilder, styler, marshal);\n this.init();\n this.setValue(this.nativeElement.getAttribute('src') || '', '');\n if (isPlatformServer(this.platformId) && this.serverModuleLoaded) {\n this.nativeElement.setAttribute('src', '');\n }\n }\n\n /**\n * Use the [responsively] activated input value to update\n * the host img src attribute or assign a default `img.src=''`\n * if the src has not been defined.\n *\n * Do nothing to standard `` usages, only when responsive\n * keys are present do we actually call `setAttribute()`\n */\n protected override updateWithValue(value?: string) {\n const url = value || this.defaultSrc;\n if (isPlatformServer(this.platformId) && this.serverModuleLoaded) {\n this.addStyles(url);\n } else {\n this.nativeElement.setAttribute('src', url);\n }\n }\n\n protected override styleCache = imgSrcCache;\n}\n\nconst imgSrcCache: Map = new Map();\n\nconst inputs = [\n 'src.xs', 'src.sm', 'src.md', 'src.lg', 'src.xl',\n 'src.lt-sm', 'src.lt-md', 'src.lt-lg', 'src.lt-xl',\n 'src.gt-xs', 'src.gt-sm', 'src.gt-md', 'src.gt-lg'\n];\n\nconst selector = `\n img[src.xs], img[src.sm], img[src.md], img[src.lg], img[src.xl],\n img[src.lt-sm], img[src.lt-md], img[src.lt-lg], img[src.lt-xl],\n img[src.gt-xs], img[src.gt-sm], img[src.gt-md], img[src.gt-lg]\n`;\n\n/**\n * This directive provides a responsive API for the HTML 'src' attribute\n * and will update the img.src property upon each responsive activation.\n *\n * e.g.\n * \n *\n * @see https://css-tricks.com/responsive-images-youre-just-changing-resolutions-use-src/\n */\n@Directive({selector, inputs})\nexport class DefaultImgSrcDirective extends ImgSrcDirective {\n protected override inputs = inputs;\n}\n","/**\n * @license\n * Copyright Google LLC All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\nimport {\n Directive,\n DoCheck,\n ElementRef,\n Input,\n IterableDiffers,\n KeyValueDiffers,\n Optional,\n Renderer2,\n Self,\n} from '@angular/core';\nimport {NgClass} from '@angular/common';\nimport {BaseDirective2, StyleUtils, MediaMarshaller} from '@angular/flex-layout/core';\n\n@Directive()\nexport class ClassDirective extends BaseDirective2 implements DoCheck {\n\n protected override DIRECTIVE_KEY = 'ngClass';\n\n /**\n * Capture class assignments so we cache the default classes\n * which are merged with activated styles and used as fallbacks.\n */\n @Input('class')\n set klass(val: string) {\n this.ngClassInstance.klass = val;\n this.setValue(val, '');\n }\n\n constructor(elementRef: ElementRef,\n styler: StyleUtils,\n marshal: MediaMarshaller,\n iterableDiffers: IterableDiffers,\n keyValueDiffers: KeyValueDiffers,\n renderer2: Renderer2,\n @Optional() @Self() protected readonly ngClassInstance: NgClass) {\n super(elementRef, null!, styler, marshal);\n if (!this.ngClassInstance) {\n // Create an instance NgClass Directive instance only if `ngClass=\"\"` has NOT been defined on\n // the same host element; since the responsive variations may be defined...\n this.ngClassInstance = new NgClass(iterableDiffers, keyValueDiffers, elementRef, renderer2);\n }\n this.init();\n this.setValue('', '');\n }\n\n protected override updateWithValue(value: any) {\n this.ngClassInstance.ngClass = value;\n this.ngClassInstance.ngDoCheck();\n }\n\n // ******************************************************************\n // Lifecycle Hooks\n // ******************************************************************\n\n /**\n * For ChangeDetectionStrategy.onPush and ngOnChanges() updates\n */\n ngDoCheck() {\n this.ngClassInstance.ngDoCheck();\n }\n}\n\nconst inputs = [\n 'ngClass', 'ngClass.xs', 'ngClass.sm', 'ngClass.md', 'ngClass.lg', 'ngClass.xl',\n 'ngClass.lt-sm', 'ngClass.lt-md', 'ngClass.lt-lg', 'ngClass.lt-xl',\n 'ngClass.gt-xs', 'ngClass.gt-sm', 'ngClass.gt-md', 'ngClass.gt-lg'\n];\n\nconst selector = `\n [ngClass], [ngClass.xs], [ngClass.sm], [ngClass.md], [ngClass.lg], [ngClass.xl],\n [ngClass.lt-sm], [ngClass.lt-md], [ngClass.lt-lg], [ngClass.lt-xl],\n [ngClass.gt-xs], [ngClass.gt-sm], [ngClass.gt-md], [ngClass.gt-lg]\n`;\n\n/**\n * Directive to add responsive support for ngClass.\n * This maintains the core functionality of 'ngClass' and adds responsive API\n * Note: this class is a no-op when rendered on the server\n */\n@Directive({selector, inputs})\nexport class DefaultClassDirective extends ClassDirective {\n protected override inputs = inputs;\n}\n","/**\n * @license\n * Copyright Google LLC All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\nimport {\n Directive,\n ElementRef,\n OnChanges,\n SimpleChanges,\n Inject,\n PLATFORM_ID,\n Injectable,\n AfterViewInit,\n} from '@angular/core';\nimport {isPlatformServer} from '@angular/common';\nimport {\n BaseDirective2,\n LAYOUT_CONFIG,\n LayoutConfigOptions,\n MediaMarshaller,\n SERVER_TOKEN,\n StyleUtils,\n StyleBuilder,\n} from '@angular/flex-layout/core';\nimport {coerceBooleanProperty} from '@angular/cdk/coercion';\nimport {takeUntil} from 'rxjs/operators';\n\nexport interface ShowHideParent {\n display: string;\n isServer: boolean;\n}\n\n@Injectable({providedIn: 'root'})\nexport class ShowHideStyleBuilder extends StyleBuilder {\n buildStyles(show: string, parent: ShowHideParent) {\n const shouldShow = show === 'true';\n return {'display': shouldShow ? parent.display || (parent.isServer ? 'initial' : '') : 'none'};\n }\n}\n\n@Directive()\nexport class ShowHideDirective extends BaseDirective2 implements AfterViewInit, OnChanges {\n protected override DIRECTIVE_KEY = 'show-hide';\n\n /** Original DOM Element CSS display style */\n protected display: string = '';\n protected hasLayout = false;\n protected hasFlexChild = false;\n\n constructor(elementRef: ElementRef,\n styleBuilder: ShowHideStyleBuilder,\n styler: StyleUtils,\n marshal: MediaMarshaller,\n @Inject(LAYOUT_CONFIG) protected layoutConfig: LayoutConfigOptions,\n @Inject(PLATFORM_ID) protected platformId: Object,\n @Inject(SERVER_TOKEN) protected serverModuleLoaded: boolean) {\n super(elementRef, styleBuilder, styler, marshal);\n }\n\n // *********************************************\n // Lifecycle Methods\n // *********************************************\n\n ngAfterViewInit() {\n this.trackExtraTriggers();\n\n const children = Array.from(this.nativeElement.children);\n for (let i = 0; i < children.length; i++) {\n if (this.marshal.hasValue(children[i] as HTMLElement, 'flex')) {\n this.hasFlexChild = true;\n break;\n }\n }\n\n if (DISPLAY_MAP.has(this.nativeElement)) {\n this.display = DISPLAY_MAP.get(this.nativeElement)!;\n } else {\n this.display = this.getDisplayStyle();\n DISPLAY_MAP.set(this.nativeElement, this.display);\n }\n\n this.init();\n // set the default to show unless explicitly overridden\n const defaultValue = this.marshal.getValue(this.nativeElement, this.DIRECTIVE_KEY, '');\n if (defaultValue === undefined || defaultValue === '') {\n this.setValue(true, '');\n } else {\n this.triggerUpdate();\n }\n }\n\n /**\n * On changes to any @Input properties...\n * Default to use the non-responsive Input value ('fxShow')\n * Then conditionally override with the mq-activated Input's current value\n */\n override ngOnChanges(changes: SimpleChanges) {\n Object.keys(changes).forEach(key => {\n if (this.inputs.indexOf(key) !== -1) {\n const inputKey = key.split('.');\n const bp = inputKey.slice(1).join('.');\n const inputValue = changes[key].currentValue;\n let shouldShow = inputValue !== '' ?\n inputValue !== 0 ? coerceBooleanProperty(inputValue) : false\n : true;\n if (inputKey[0] === 'fxHide') {\n shouldShow = !shouldShow;\n }\n this.setValue(shouldShow, bp);\n }\n });\n }\n\n // *********************************************\n // Protected methods\n // *********************************************\n\n /**\n * Watch for these extra triggers to update fxShow, fxHide stylings\n */\n protected trackExtraTriggers() {\n this.hasLayout = this.marshal.hasValue(this.nativeElement, 'layout');\n\n ['layout', 'layout-align'].forEach(key => {\n this.marshal\n .trackValue(this.nativeElement, key)\n .pipe(takeUntil(this.destroySubject))\n .subscribe(this.triggerUpdate.bind(this));\n });\n }\n\n /**\n * Override accessor to the current HTMLElement's `display` style\n * Note: Show/Hide will not change the display to 'flex' but will set it to 'block'\n * unless it was already explicitly specified inline or in a CSS stylesheet.\n */\n protected getDisplayStyle(): string {\n return (this.hasLayout || (this.hasFlexChild && this.layoutConfig.addFlexToParent)) ?\n 'flex' : this.styler.lookupStyle(this.nativeElement, 'display', true);\n }\n\n /** Validate the visibility value and then update the host's inline display style */\n protected override updateWithValue(value: boolean | string = true) {\n if (value === '') {\n return;\n }\n const isServer = isPlatformServer(this.platformId);\n this.addStyles(value ? 'true' : 'false', {display: this.display, isServer});\n if (isServer && this.serverModuleLoaded) {\n this.nativeElement.style.setProperty('display', '');\n }\n this.marshal.triggerUpdate(this.parentElement!, 'layout-gap');\n }\n}\n\nconst DISPLAY_MAP: WeakMap = new WeakMap();\n\nconst inputs = [\n 'fxShow', 'fxShow.print',\n 'fxShow.xs', 'fxShow.sm', 'fxShow.md', 'fxShow.lg', 'fxShow.xl',\n 'fxShow.lt-sm', 'fxShow.lt-md', 'fxShow.lt-lg', 'fxShow.lt-xl',\n 'fxShow.gt-xs', 'fxShow.gt-sm', 'fxShow.gt-md', 'fxShow.gt-lg',\n 'fxHide', 'fxHide.print',\n 'fxHide.xs', 'fxHide.sm', 'fxHide.md', 'fxHide.lg', 'fxHide.xl',\n 'fxHide.lt-sm', 'fxHide.lt-md', 'fxHide.lt-lg', 'fxHide.lt-xl',\n 'fxHide.gt-xs', 'fxHide.gt-sm', 'fxHide.gt-md', 'fxHide.gt-lg'\n];\n\nconst selector = `\n [fxShow], [fxShow.print],\n [fxShow.xs], [fxShow.sm], [fxShow.md], [fxShow.lg], [fxShow.xl],\n [fxShow.lt-sm], [fxShow.lt-md], [fxShow.lt-lg], [fxShow.lt-xl],\n [fxShow.gt-xs], [fxShow.gt-sm], [fxShow.gt-md], [fxShow.gt-lg],\n [fxHide], [fxHide.print],\n [fxHide.xs], [fxHide.sm], [fxHide.md], [fxHide.lg], [fxHide.xl],\n [fxHide.lt-sm], [fxHide.lt-md], [fxHide.lt-lg], [fxHide.lt-xl],\n [fxHide.gt-xs], [fxHide.gt-sm], [fxHide.gt-md], [fxHide.gt-lg]\n`;\n\n/**\n * 'show' Layout API directive\n */\n@Directive({selector, inputs})\nexport class DefaultShowHideDirective extends ShowHideDirective {\n protected override inputs = inputs;\n}\n","/**\n * @license\n * Copyright Google LLC All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\n\nexport type NgStyleRawList = string[];\nexport type NgStyleMap = {[klass: string]: string};\n// NgStyle selectors accept NgStyleType values\nexport type NgStyleType = string | Set | NgStyleRawList | NgStyleMap;\n\n/**\n * Callback function for SecurityContext.STYLE sanitization\n */\nexport type NgStyleSanitizer = (val: any) => string;\n\n/** NgStyle allowed inputs */\nexport class NgStyleKeyValue {\n constructor(public key: string, public value: string, noQuotes = true) {\n this.key = noQuotes ? key.replace(/['\"]/g, '').trim() : key.trim();\n\n this.value = noQuotes ? value.replace(/['\"]/g, '').trim() : value.trim();\n this.value = this.value.replace(/;/, '');\n }\n}\n\nexport function getType(target: any): string {\n let what = typeof target;\n if (what === 'object') {\n return (target.constructor === Array) ? 'array' :\n (target.constructor === Set) ? 'set' : 'object';\n }\n return what;\n}\n\n/**\n * Split string of key:value pairs into Array of k-v pairs\n * e.g. 'key:value; key:value; key:value;' -> ['key:value',...]\n */\nexport function buildRawList(source: any, delimiter = ';'): NgStyleRawList {\n return String(source)\n .trim()\n .split(delimiter)\n .map((val: string) => val.trim())\n .filter(val => val !== '');\n}\n\n/** Convert array of key:value strings to a iterable map object */\nexport function buildMapFromList(styles: NgStyleRawList, sanitize?: NgStyleSanitizer): NgStyleMap {\n const sanitizeValue = (it: NgStyleKeyValue) => {\n if (sanitize) {\n it.value = sanitize(it.value);\n }\n return it;\n };\n\n return styles\n .map(stringToKeyValue)\n .filter(entry => !!entry)\n .map(sanitizeValue)\n .reduce(keyValuesToMap, {} as NgStyleMap);\n}\n\n/** Convert Set or raw Object to an iterable NgStyleMap */\nexport function buildMapFromSet(source: NgStyleType, sanitize?: NgStyleSanitizer): NgStyleMap {\n let list: string[] = [];\n if (getType(source) === 'set') {\n (source as Set).forEach(entry => list.push(entry));\n } else {\n Object.keys(source).forEach((key: string) => {\n list.push(`${key}:${(source as NgStyleMap)[key]}`);\n });\n }\n return buildMapFromList(list, sanitize);\n}\n\n\n/** Convert 'key:value' -> [key, value] */\nexport function stringToKeyValue(it: string): NgStyleKeyValue {\n const [key, ...vals] = it.split(':');\n return new NgStyleKeyValue(key, vals.join(':'));\n}\n\n/** Convert [ [key,value] ] -> { key : value } */\nexport function keyValuesToMap(map: NgStyleMap, entry: NgStyleKeyValue): NgStyleMap {\n if (!!entry.key) {\n map[entry.key] = entry.value;\n }\n return map;\n}\n","/**\n * @license\n * Copyright Google LLC All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\nimport {\n Directive,\n DoCheck,\n ElementRef,\n Inject,\n KeyValueDiffers,\n Optional,\n PLATFORM_ID,\n Renderer2,\n SecurityContext,\n Self,\n} from '@angular/core';\nimport {isPlatformServer, NgStyle} from '@angular/common';\nimport {DomSanitizer} from '@angular/platform-browser';\nimport {\n BaseDirective2,\n StyleUtils,\n MediaMarshaller,\n SERVER_TOKEN,\n} from '@angular/flex-layout/core';\n\nimport {\n NgStyleRawList,\n NgStyleType,\n NgStyleSanitizer,\n buildRawList,\n getType,\n buildMapFromSet,\n NgStyleMap,\n NgStyleKeyValue,\n stringToKeyValue,\n keyValuesToMap,\n} from './style-transforms';\n\n@Directive()\nexport class StyleDirective extends BaseDirective2 implements DoCheck {\n\n protected override DIRECTIVE_KEY = 'ngStyle';\n protected fallbackStyles: NgStyleMap;\n protected isServer: boolean;\n\n constructor(elementRef: ElementRef,\n styler: StyleUtils,\n marshal: MediaMarshaller,\n protected sanitizer: DomSanitizer,\n differs: KeyValueDiffers,\n renderer2: Renderer2,\n @Optional() @Self() private readonly ngStyleInstance: NgStyle,\n @Inject(SERVER_TOKEN) serverLoaded: boolean,\n @Inject(PLATFORM_ID) platformId: Object) {\n super(elementRef, null!, styler, marshal);\n if (!this.ngStyleInstance) {\n // Create an instance NgStyle Directive instance only if `ngStyle=\"\"` has NOT been\n // defined on the same host element; since the responsive variations may be defined...\n this.ngStyleInstance = new NgStyle(elementRef, differs, renderer2);\n }\n this.init();\n const styles = this.nativeElement.getAttribute('style') ?? '';\n this.fallbackStyles = this.buildStyleMap(styles);\n this.isServer = serverLoaded && isPlatformServer(platformId);\n }\n\n /** Add generated styles */\n protected override updateWithValue(value: any) {\n const styles = this.buildStyleMap(value);\n this.ngStyleInstance.ngStyle = {...this.fallbackStyles, ...styles};\n if (this.isServer) {\n this.applyStyleToElement(styles);\n }\n this.ngStyleInstance.ngDoCheck();\n }\n\n /** Remove generated styles */\n protected override clearStyles() {\n this.ngStyleInstance.ngStyle = this.fallbackStyles;\n this.ngStyleInstance.ngDoCheck();\n }\n\n /**\n * Convert raw strings to ngStyleMap; which is required by ngStyle\n * NOTE: Raw string key-value pairs MUST be delimited by `;`\n * Comma-delimiters are not supported due to complexities of\n * possible style values such as `rgba(x,x,x,x)` and others\n */\n protected buildStyleMap(styles: NgStyleType): NgStyleMap {\n // Always safe-guard (aka sanitize) style property values\n const sanitizer: NgStyleSanitizer = (val: any) =>\n this.sanitizer.sanitize(SecurityContext.STYLE, val) ?? '';\n if (styles) {\n switch (getType(styles)) {\n case 'string': return buildMapFromList(buildRawList(styles),\n sanitizer);\n case 'array' : return buildMapFromList(styles as NgStyleRawList, sanitizer);\n case 'set' : return buildMapFromSet(styles, sanitizer);\n default : return buildMapFromSet(styles, sanitizer);\n }\n }\n\n return {};\n }\n\n // ******************************************************************\n // Lifecycle Hooks\n // ******************************************************************\n\n /** For ChangeDetectionStrategy.onPush and ngOnChanges() updates */\n ngDoCheck() {\n this.ngStyleInstance.ngDoCheck();\n }\n}\n\nconst inputs = [\n 'ngStyle',\n 'ngStyle.xs', 'ngStyle.sm', 'ngStyle.md', 'ngStyle.lg', 'ngStyle.xl',\n 'ngStyle.lt-sm', 'ngStyle.lt-md', 'ngStyle.lt-lg', 'ngStyle.lt-xl',\n 'ngStyle.gt-xs', 'ngStyle.gt-sm', 'ngStyle.gt-md', 'ngStyle.gt-lg'\n];\n\nconst selector = `\n [ngStyle],\n [ngStyle.xs], [ngStyle.sm], [ngStyle.md], [ngStyle.lg], [ngStyle.xl],\n [ngStyle.lt-sm], [ngStyle.lt-md], [ngStyle.lt-lg], [ngStyle.lt-xl],\n [ngStyle.gt-xs], [ngStyle.gt-sm], [ngStyle.gt-md], [ngStyle.gt-lg]\n`;\n\n/**\n * Directive to add responsive support for ngStyle.\n *\n */\n@Directive({selector, inputs})\nexport class DefaultStyleDirective extends StyleDirective implements DoCheck {\n protected override inputs = inputs;\n}\n\n/** Build a styles map from a list of styles, while sanitizing bad values first */\nfunction buildMapFromList(styles: NgStyleRawList, sanitize?: NgStyleSanitizer): NgStyleMap {\n const sanitizeValue = (it: NgStyleKeyValue) => {\n if (sanitize) {\n it.value = sanitize(it.value);\n }\n return it;\n };\n\n return styles\n .map(stringToKeyValue)\n .filter(entry => !!entry)\n .map(sanitizeValue)\n .reduce(keyValuesToMap, {} as NgStyleMap);\n}\n","/**\n * @license\n * Copyright Google LLC All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\nimport {NgModule} from '@angular/core';\nimport {CoreModule} from '@angular/flex-layout/core';\n\nimport {DefaultImgSrcDirective} from './img-src/img-src';\nimport {DefaultClassDirective} from './class/class';\nimport {DefaultShowHideDirective} from './show-hide/show-hide';\nimport {DefaultStyleDirective} from './style/style';\n\n\nconst ALL_DIRECTIVES = [\n DefaultShowHideDirective,\n DefaultClassDirective,\n DefaultStyleDirective,\n DefaultImgSrcDirective,\n];\n\n/**\n * *****************************************************************\n * Define module for the Extended API\n * *****************************************************************\n */\n\n@NgModule({\n imports: [CoreModule],\n declarations: [...ALL_DIRECTIVES],\n exports: [...ALL_DIRECTIVES]\n})\nexport class ExtendedModule {\n}\n","/**\n * @license\n * Copyright Google LLC All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\n\nexport * from './module';\n\nexport * from './class/class';\nexport * from './img-src/img-src';\nexport * from './show-hide/show-hide';\nexport * from './style/style';\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":["inputs","selector","buildMapFromList","i2","i3"],"mappings":"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"} -\ No newline at end of file -+{"version":3,"file":"angular-flex-layout-extended.mjs","sources":["../../../../projects/libs/flex-layout/extended/img-src/img-src.ts","../../../../projects/libs/flex-layout/extended/class/class.ts","../../../../projects/libs/flex-layout/extended/show-hide/show-hide.ts","../../../../projects/libs/flex-layout/extended/style/style-transforms.ts","../../../../projects/libs/flex-layout/extended/style/style.ts","../../../../projects/libs/flex-layout/extended/module.ts","../../../../projects/libs/flex-layout/extended/public-api.ts","../../../../projects/libs/flex-layout/extended/angular-flex-layout-extended.ts"],"sourcesContent":["/**\n * @license\n * Copyright Google LLC All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\nimport {Directive, ElementRef, Inject, PLATFORM_ID, Injectable, Input} from '@angular/core';\nimport {isPlatformServer} from '@angular/common';\nimport {\n MediaMarshaller,\n BaseDirective2,\n SERVER_TOKEN,\n StyleBuilder,\n StyleDefinition,\n StyleUtils,\n} from '@angular/flex-layout/core';\n\n@Injectable({providedIn: 'root'})\nexport class ImgSrcStyleBuilder extends StyleBuilder {\n buildStyles(url: string) {\n return {'content': url ? `url(${url})` : ''};\n }\n}\n\n@Directive()\nexport class ImgSrcDirective extends BaseDirective2 {\n protected override DIRECTIVE_KEY = 'img-src';\n protected defaultSrc = '';\n\n @Input('src')\n set src(val: string) {\n this.defaultSrc = val;\n this.setValue(this.defaultSrc, '');\n }\n\n constructor(elementRef: ElementRef,\n styleBuilder: ImgSrcStyleBuilder,\n styler: StyleUtils,\n marshal: MediaMarshaller,\n @Inject(PLATFORM_ID) protected platformId: Object,\n @Inject(SERVER_TOKEN) protected serverModuleLoaded: boolean) {\n super(elementRef, styleBuilder, styler, marshal);\n this.init();\n this.setValue(this.nativeElement.getAttribute('src') || '', '');\n if (isPlatformServer(this.platformId) && this.serverModuleLoaded) {\n this.nativeElement.setAttribute('src', '');\n }\n }\n\n /**\n * Use the [responsively] activated input value to update\n * the host img src attribute or assign a default `img.src=''`\n * if the src has not been defined.\n *\n * Do nothing to standard `` usages, only when responsive\n * keys are present do we actually call `setAttribute()`\n */\n protected override updateWithValue(value?: string) {\n const url = value || this.defaultSrc;\n if (isPlatformServer(this.platformId) && this.serverModuleLoaded) {\n this.addStyles(url);\n } else {\n this.nativeElement.setAttribute('src', url);\n }\n }\n\n protected override styleCache = imgSrcCache;\n}\n\nconst imgSrcCache: Map = new Map();\n\nconst inputs = [\n 'src.xs', 'src.sm', 'src.md', 'src.lg', 'src.xl',\n 'src.lt-sm', 'src.lt-md', 'src.lt-lg', 'src.lt-xl',\n 'src.gt-xs', 'src.gt-sm', 'src.gt-md', 'src.gt-lg'\n];\n\nconst selector = `\n img[src.xs], img[src.sm], img[src.md], img[src.lg], img[src.xl],\n img[src.lt-sm], img[src.lt-md], img[src.lt-lg], img[src.lt-xl],\n img[src.gt-xs], img[src.gt-sm], img[src.gt-md], img[src.gt-lg]\n`;\n\n/**\n * This directive provides a responsive API for the HTML 'src' attribute\n * and will update the img.src property upon each responsive activation.\n *\n * e.g.\n * \n *\n * @see https://css-tricks.com/responsive-images-youre-just-changing-resolutions-use-src/\n */\n@Directive({selector, inputs})\nexport class DefaultImgSrcDirective extends ImgSrcDirective {\n protected override inputs = inputs;\n}\n","/**\n * @license\n * Copyright Google LLC All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\nimport {\n Directive,\n DoCheck,\n ElementRef,\n Input,\n IterableDiffers,\n KeyValueDiffers,\n Optional,\n Renderer2,\n Self,\n} from '@angular/core';\nimport {NgClass} from '@angular/common';\nimport {BaseDirective2, StyleUtils, MediaMarshaller} from '@angular/flex-layout/core';\n\n@Directive()\nexport class ClassDirective extends BaseDirective2 implements DoCheck {\n\n protected override DIRECTIVE_KEY = 'ngClass';\n\n /**\n * Capture class assignments so we cache the default classes\n * which are merged with activated styles and used as fallbacks.\n */\n @Input('class')\n set klass(val: string) {\n this.ngClassInstance.klass = val;\n this.setValue(val, '');\n }\n\n constructor(elementRef: ElementRef,\n styler: StyleUtils,\n marshal: MediaMarshaller,\n iterableDiffers: IterableDiffers,\n keyValueDiffers: KeyValueDiffers,\n renderer2: Renderer2,\n @Optional() @Self() protected readonly ngClassInstance: NgClass) {\n super(elementRef, null!, styler, marshal);\n if (!this.ngClassInstance) {\n // Create an instance NgClass Directive instance only if `ngClass=\"\"` has NOT been defined on\n // the same host element; since the responsive variations may be defined...\n this.ngClassInstance = new NgClass(elementRef, renderer2);\n }\n this.init();\n this.setValue('', '');\n }\n\n protected override updateWithValue(value: any) {\n this.ngClassInstance.ngClass = value;\n this.ngClassInstance.ngDoCheck();\n }\n\n // ******************************************************************\n // Lifecycle Hooks\n // ******************************************************************\n\n /**\n * For ChangeDetectionStrategy.onPush and ngOnChanges() updates\n */\n ngDoCheck() {\n this.ngClassInstance.ngDoCheck();\n }\n}\n\nconst inputs = [\n 'ngClass', 'ngClass.xs', 'ngClass.sm', 'ngClass.md', 'ngClass.lg', 'ngClass.xl',\n 'ngClass.lt-sm', 'ngClass.lt-md', 'ngClass.lt-lg', 'ngClass.lt-xl',\n 'ngClass.gt-xs', 'ngClass.gt-sm', 'ngClass.gt-md', 'ngClass.gt-lg'\n];\n\nconst selector = `\n [ngClass], [ngClass.xs], [ngClass.sm], [ngClass.md], [ngClass.lg], [ngClass.xl],\n [ngClass.lt-sm], [ngClass.lt-md], [ngClass.lt-lg], [ngClass.lt-xl],\n [ngClass.gt-xs], [ngClass.gt-sm], [ngClass.gt-md], [ngClass.gt-lg]\n`;\n\n/**\n * Directive to add responsive support for ngClass.\n * This maintains the core functionality of 'ngClass' and adds responsive API\n * Note: this class is a no-op when rendered on the server\n */\n@Directive({selector, inputs})\nexport class DefaultClassDirective extends ClassDirective {\n protected override inputs = inputs;\n}\n","/**\n * @license\n * Copyright Google LLC All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\nimport {\n Directive,\n ElementRef,\n OnChanges,\n SimpleChanges,\n Inject,\n PLATFORM_ID,\n Injectable,\n AfterViewInit,\n} from '@angular/core';\nimport {isPlatformServer} from '@angular/common';\nimport {\n BaseDirective2,\n LAYOUT_CONFIG,\n LayoutConfigOptions,\n MediaMarshaller,\n SERVER_TOKEN,\n StyleUtils,\n StyleBuilder,\n} from '@angular/flex-layout/core';\nimport {coerceBooleanProperty} from '@angular/cdk/coercion';\nimport {takeUntil} from 'rxjs/operators';\n\nexport interface ShowHideParent {\n display: string;\n isServer: boolean;\n}\n\n@Injectable({providedIn: 'root'})\nexport class ShowHideStyleBuilder extends StyleBuilder {\n buildStyles(show: string, parent: ShowHideParent) {\n const shouldShow = show === 'true';\n return {'display': shouldShow ? parent.display || (parent.isServer ? 'initial' : '') : 'none'};\n }\n}\n\n@Directive()\nexport class ShowHideDirective extends BaseDirective2 implements AfterViewInit, OnChanges {\n protected override DIRECTIVE_KEY = 'show-hide';\n\n /** Original DOM Element CSS display style */\n protected display: string = '';\n protected hasLayout = false;\n protected hasFlexChild = false;\n\n constructor(elementRef: ElementRef,\n styleBuilder: ShowHideStyleBuilder,\n styler: StyleUtils,\n marshal: MediaMarshaller,\n @Inject(LAYOUT_CONFIG) protected layoutConfig: LayoutConfigOptions,\n @Inject(PLATFORM_ID) protected platformId: Object,\n @Inject(SERVER_TOKEN) protected serverModuleLoaded: boolean) {\n super(elementRef, styleBuilder, styler, marshal);\n }\n\n // *********************************************\n // Lifecycle Methods\n // *********************************************\n\n ngAfterViewInit() {\n this.trackExtraTriggers();\n\n const children = Array.from(this.nativeElement.children);\n for (let i = 0; i < children.length; i++) {\n if (this.marshal.hasValue(children[i] as HTMLElement, 'flex')) {\n this.hasFlexChild = true;\n break;\n }\n }\n\n if (DISPLAY_MAP.has(this.nativeElement)) {\n this.display = DISPLAY_MAP.get(this.nativeElement)!;\n } else {\n this.display = this.getDisplayStyle();\n DISPLAY_MAP.set(this.nativeElement, this.display);\n }\n\n this.init();\n // set the default to show unless explicitly overridden\n const defaultValue = this.marshal.getValue(this.nativeElement, this.DIRECTIVE_KEY, '');\n if (defaultValue === undefined || defaultValue === '') {\n this.setValue(true, '');\n } else {\n this.triggerUpdate();\n }\n }\n\n /**\n * On changes to any @Input properties...\n * Default to use the non-responsive Input value ('fxShow')\n * Then conditionally override with the mq-activated Input's current value\n */\n override ngOnChanges(changes: SimpleChanges) {\n Object.keys(changes).forEach(key => {\n if (this.inputs.indexOf(key) !== -1) {\n const inputKey = key.split('.');\n const bp = inputKey.slice(1).join('.');\n const inputValue = changes[key].currentValue;\n let shouldShow = inputValue !== '' ?\n inputValue !== 0 ? coerceBooleanProperty(inputValue) : false\n : true;\n if (inputKey[0] === 'fxHide') {\n shouldShow = !shouldShow;\n }\n this.setValue(shouldShow, bp);\n }\n });\n }\n\n // *********************************************\n // Protected methods\n // *********************************************\n\n /**\n * Watch for these extra triggers to update fxShow, fxHide stylings\n */\n protected trackExtraTriggers() {\n this.hasLayout = this.marshal.hasValue(this.nativeElement, 'layout');\n\n ['layout', 'layout-align'].forEach(key => {\n this.marshal\n .trackValue(this.nativeElement, key)\n .pipe(takeUntil(this.destroySubject))\n .subscribe(this.triggerUpdate.bind(this));\n });\n }\n\n /**\n * Override accessor to the current HTMLElement's `display` style\n * Note: Show/Hide will not change the display to 'flex' but will set it to 'block'\n * unless it was already explicitly specified inline or in a CSS stylesheet.\n */\n protected getDisplayStyle(): string {\n return (this.hasLayout || (this.hasFlexChild && this.layoutConfig.addFlexToParent)) ?\n 'flex' : this.styler.lookupStyle(this.nativeElement, 'display', true);\n }\n\n /** Validate the visibility value and then update the host's inline display style */\n protected override updateWithValue(value: boolean | string = true) {\n if (value === '') {\n return;\n }\n const isServer = isPlatformServer(this.platformId);\n this.addStyles(value ? 'true' : 'false', {display: this.display, isServer});\n if (isServer && this.serverModuleLoaded) {\n this.nativeElement.style.setProperty('display', '');\n }\n this.marshal.triggerUpdate(this.parentElement!, 'layout-gap');\n }\n}\n\nconst DISPLAY_MAP: WeakMap = new WeakMap();\n\nconst inputs = [\n 'fxShow', 'fxShow.print',\n 'fxShow.xs', 'fxShow.sm', 'fxShow.md', 'fxShow.lg', 'fxShow.xl',\n 'fxShow.lt-sm', 'fxShow.lt-md', 'fxShow.lt-lg', 'fxShow.lt-xl',\n 'fxShow.gt-xs', 'fxShow.gt-sm', 'fxShow.gt-md', 'fxShow.gt-lg',\n 'fxHide', 'fxHide.print',\n 'fxHide.xs', 'fxHide.sm', 'fxHide.md', 'fxHide.lg', 'fxHide.xl',\n 'fxHide.lt-sm', 'fxHide.lt-md', 'fxHide.lt-lg', 'fxHide.lt-xl',\n 'fxHide.gt-xs', 'fxHide.gt-sm', 'fxHide.gt-md', 'fxHide.gt-lg'\n];\n\nconst selector = `\n [fxShow], [fxShow.print],\n [fxShow.xs], [fxShow.sm], [fxShow.md], [fxShow.lg], [fxShow.xl],\n [fxShow.lt-sm], [fxShow.lt-md], [fxShow.lt-lg], [fxShow.lt-xl],\n [fxShow.gt-xs], [fxShow.gt-sm], [fxShow.gt-md], [fxShow.gt-lg],\n [fxHide], [fxHide.print],\n [fxHide.xs], [fxHide.sm], [fxHide.md], [fxHide.lg], [fxHide.xl],\n [fxHide.lt-sm], [fxHide.lt-md], [fxHide.lt-lg], [fxHide.lt-xl],\n [fxHide.gt-xs], [fxHide.gt-sm], [fxHide.gt-md], [fxHide.gt-lg]\n`;\n\n/**\n * 'show' Layout API directive\n */\n@Directive({selector, inputs})\nexport class DefaultShowHideDirective extends ShowHideDirective {\n protected override inputs = inputs;\n}\n","/**\n * @license\n * Copyright Google LLC All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\n\nexport type NgStyleRawList = string[];\nexport type NgStyleMap = {[klass: string]: string};\n// NgStyle selectors accept NgStyleType values\nexport type NgStyleType = string | Set | NgStyleRawList | NgStyleMap;\n\n/**\n * Callback function for SecurityContext.STYLE sanitization\n */\nexport type NgStyleSanitizer = (val: any) => string;\n\n/** NgStyle allowed inputs */\nexport class NgStyleKeyValue {\n constructor(public key: string, public value: string, noQuotes = true) {\n this.key = noQuotes ? key.replace(/['\"]/g, '').trim() : key.trim();\n\n this.value = noQuotes ? value.replace(/['\"]/g, '').trim() : value.trim();\n this.value = this.value.replace(/;/, '');\n }\n}\n\nexport function getType(target: any): string {\n let what = typeof target;\n if (what === 'object') {\n return (target.constructor === Array) ? 'array' :\n (target.constructor === Set) ? 'set' : 'object';\n }\n return what;\n}\n\n/**\n * Split string of key:value pairs into Array of k-v pairs\n * e.g. 'key:value; key:value; key:value;' -> ['key:value',...]\n */\nexport function buildRawList(source: any, delimiter = ';'): NgStyleRawList {\n return String(source)\n .trim()\n .split(delimiter)\n .map((val: string) => val.trim())\n .filter(val => val !== '');\n}\n\n/** Convert array of key:value strings to a iterable map object */\nexport function buildMapFromList(styles: NgStyleRawList, sanitize?: NgStyleSanitizer): NgStyleMap {\n const sanitizeValue = (it: NgStyleKeyValue) => {\n if (sanitize) {\n it.value = sanitize(it.value);\n }\n return it;\n };\n\n return styles\n .map(stringToKeyValue)\n .filter(entry => !!entry)\n .map(sanitizeValue)\n .reduce(keyValuesToMap, {} as NgStyleMap);\n}\n\n/** Convert Set or raw Object to an iterable NgStyleMap */\nexport function buildMapFromSet(source: NgStyleType, sanitize?: NgStyleSanitizer): NgStyleMap {\n let list: string[] = [];\n if (getType(source) === 'set') {\n (source as Set).forEach(entry => list.push(entry));\n } else {\n Object.keys(source).forEach((key: string) => {\n list.push(`${key}:${(source as NgStyleMap)[key]}`);\n });\n }\n return buildMapFromList(list, sanitize);\n}\n\n\n/** Convert 'key:value' -> [key, value] */\nexport function stringToKeyValue(it: string): NgStyleKeyValue {\n const [key, ...vals] = it.split(':');\n return new NgStyleKeyValue(key, vals.join(':'));\n}\n\n/** Convert [ [key,value] ] -> { key : value } */\nexport function keyValuesToMap(map: NgStyleMap, entry: NgStyleKeyValue): NgStyleMap {\n if (!!entry.key) {\n map[entry.key] = entry.value;\n }\n return map;\n}\n","/**\n * @license\n * Copyright Google LLC All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\nimport {\n Directive,\n DoCheck,\n ElementRef,\n Inject,\n KeyValueDiffers,\n Optional,\n PLATFORM_ID,\n Renderer2,\n SecurityContext,\n Self,\n} from '@angular/core';\nimport {isPlatformServer, NgStyle} from '@angular/common';\nimport {DomSanitizer} from '@angular/platform-browser';\nimport {\n BaseDirective2,\n StyleUtils,\n MediaMarshaller,\n SERVER_TOKEN,\n} from '@angular/flex-layout/core';\n\nimport {\n NgStyleRawList,\n NgStyleType,\n NgStyleSanitizer,\n buildRawList,\n getType,\n buildMapFromSet,\n NgStyleMap,\n NgStyleKeyValue,\n stringToKeyValue,\n keyValuesToMap,\n} from './style-transforms';\n\n@Directive()\nexport class StyleDirective extends BaseDirective2 implements DoCheck {\n\n protected override DIRECTIVE_KEY = 'ngStyle';\n protected fallbackStyles: NgStyleMap;\n protected isServer: boolean;\n\n constructor(elementRef: ElementRef,\n styler: StyleUtils,\n marshal: MediaMarshaller,\n protected sanitizer: DomSanitizer,\n differs: KeyValueDiffers,\n renderer2: Renderer2,\n @Optional() @Self() private readonly ngStyleInstance: NgStyle,\n @Inject(SERVER_TOKEN) serverLoaded: boolean,\n @Inject(PLATFORM_ID) platformId: Object) {\n super(elementRef, null!, styler, marshal);\n if (!this.ngStyleInstance) {\n // Create an instance NgStyle Directive instance only if `ngStyle=\"\"` has NOT been\n // defined on the same host element; since the responsive variations may be defined...\n this.ngStyleInstance = new NgStyle(elementRef, differs, renderer2);\n }\n this.init();\n const styles = this.nativeElement.getAttribute('style') ?? '';\n this.fallbackStyles = this.buildStyleMap(styles);\n this.isServer = serverLoaded && isPlatformServer(platformId);\n }\n\n /** Add generated styles */\n protected override updateWithValue(value: any) {\n const styles = this.buildStyleMap(value);\n this.ngStyleInstance.ngStyle = {...this.fallbackStyles, ...styles};\n if (this.isServer) {\n this.applyStyleToElement(styles);\n }\n this.ngStyleInstance.ngDoCheck();\n }\n\n /** Remove generated styles */\n protected override clearStyles() {\n this.ngStyleInstance.ngStyle = this.fallbackStyles;\n this.ngStyleInstance.ngDoCheck();\n }\n\n /**\n * Convert raw strings to ngStyleMap; which is required by ngStyle\n * NOTE: Raw string key-value pairs MUST be delimited by `;`\n * Comma-delimiters are not supported due to complexities of\n * possible style values such as `rgba(x,x,x,x)` and others\n */\n protected buildStyleMap(styles: NgStyleType): NgStyleMap {\n // Always safe-guard (aka sanitize) style property values\n const sanitizer: NgStyleSanitizer = (val: any) =>\n this.sanitizer.sanitize(SecurityContext.STYLE, val) ?? '';\n if (styles) {\n switch (getType(styles)) {\n case 'string': return buildMapFromList(buildRawList(styles),\n sanitizer);\n case 'array' : return buildMapFromList(styles as NgStyleRawList, sanitizer);\n case 'set' : return buildMapFromSet(styles, sanitizer);\n default : return buildMapFromSet(styles, sanitizer);\n }\n }\n\n return {};\n }\n\n // ******************************************************************\n // Lifecycle Hooks\n // ******************************************************************\n\n /** For ChangeDetectionStrategy.onPush and ngOnChanges() updates */\n ngDoCheck() {\n this.ngStyleInstance.ngDoCheck();\n }\n}\n\nconst inputs = [\n 'ngStyle',\n 'ngStyle.xs', 'ngStyle.sm', 'ngStyle.md', 'ngStyle.lg', 'ngStyle.xl',\n 'ngStyle.lt-sm', 'ngStyle.lt-md', 'ngStyle.lt-lg', 'ngStyle.lt-xl',\n 'ngStyle.gt-xs', 'ngStyle.gt-sm', 'ngStyle.gt-md', 'ngStyle.gt-lg'\n];\n\nconst selector = `\n [ngStyle],\n [ngStyle.xs], [ngStyle.sm], [ngStyle.md], [ngStyle.lg], [ngStyle.xl],\n [ngStyle.lt-sm], [ngStyle.lt-md], [ngStyle.lt-lg], [ngStyle.lt-xl],\n [ngStyle.gt-xs], [ngStyle.gt-sm], [ngStyle.gt-md], [ngStyle.gt-lg]\n`;\n\n/**\n * Directive to add responsive support for ngStyle.\n *\n */\n@Directive({selector, inputs})\nexport class DefaultStyleDirective extends StyleDirective implements DoCheck {\n protected override inputs = inputs;\n}\n\n/** Build a styles map from a list of styles, while sanitizing bad values first */\nfunction buildMapFromList(styles: NgStyleRawList, sanitize?: NgStyleSanitizer): NgStyleMap {\n const sanitizeValue = (it: NgStyleKeyValue) => {\n if (sanitize) {\n it.value = sanitize(it.value);\n }\n return it;\n };\n\n return styles\n .map(stringToKeyValue)\n .filter(entry => !!entry)\n .map(sanitizeValue)\n .reduce(keyValuesToMap, {} as NgStyleMap);\n}\n","/**\n * @license\n * Copyright Google LLC All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\nimport {NgModule} from '@angular/core';\nimport {CoreModule} from '@angular/flex-layout/core';\n\nimport {DefaultImgSrcDirective} from './img-src/img-src';\nimport {DefaultClassDirective} from './class/class';\nimport {DefaultShowHideDirective} from './show-hide/show-hide';\nimport {DefaultStyleDirective} from './style/style';\n\n\nconst ALL_DIRECTIVES = [\n DefaultShowHideDirective,\n DefaultClassDirective,\n DefaultStyleDirective,\n DefaultImgSrcDirective,\n];\n\n/**\n * *****************************************************************\n * Define module for the Extended API\n * *****************************************************************\n */\n\n@NgModule({\n imports: [CoreModule],\n declarations: [...ALL_DIRECTIVES],\n exports: [...ALL_DIRECTIVES]\n})\nexport class ExtendedModule {\n}\n","/**\n * @license\n * Copyright Google LLC All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\n\nexport * from './module';\n\nexport * from './class/class';\nexport * from './img-src/img-src';\nexport * from './show-hide/show-hide';\nexport * from './style/style';\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":["inputs","selector","buildMapFromList","i2","i3"],"mappings":"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"} diff --git a/ui-ngx/patches/tooltipster+4.2.8.patch b/ui-ngx/patches/tooltipster+4.2.8.patch new file mode 100644 index 0000000000..d328300720 --- /dev/null +++ b/ui-ngx/patches/tooltipster+4.2.8.patch @@ -0,0 +1,223 @@ +diff --git a/node_modules/tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.js b/node_modules/tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.js +index 8d94dc7..8123bac 100644 +--- a/node_modules/tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.js ++++ b/node_modules/tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.js +@@ -1,32 +1,34 @@ + (function (root, factory) { ++ if (root === undefined && window !== undefined) root = window; + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module unless amdModuleId is set + define(["tooltipster"], function (a0) { + return (factory(a0)); + }); +- } else if (typeof exports === 'object') { ++ } else if (typeof module === 'object' && module.exports) { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(require("tooltipster")); + } else { +- factory(jQuery); ++ factory(root["jQuery"]); + } + }(this, function ($) { + + (function (root, factory) { ++ if (root === undefined && window !== undefined) root = window; + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module unless amdModuleId is set + define(["jquery"], function (a0) { + return (factory(a0)); + }); +- } else if (typeof exports === 'object') { ++ } else if (typeof module === 'object' && module.exports) { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(require("jquery")); + } else { +- factory(jQuery); ++ factory(root["jQuery"]); + } + }(this, function ($) { + +diff --git a/node_modules/tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.min.js b/node_modules/tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.min.js +index df77068..84bdfdd 100644 +--- a/node_modules/tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.min.js ++++ b/node_modules/tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.min.js +@@ -1 +1 @@ +-!function(a,b){"function"==typeof define&&define.amd?define(["tooltipster"],function(a){return b(a)}):"object"==typeof exports?module.exports=b(require("tooltipster")):b(jQuery)}(this,function(a){!function(a,b){"function"==typeof define&&define.amd?define(["jquery"],function(a){return b(a)}):"object"==typeof exports?module.exports=b(require("jquery")):b(jQuery)}(this,function(a){var b="tooltipster.SVG";return a.tooltipster._plugin({name:b,core:{__init:function(){a.tooltipster._on("init",function(c){var d=a.tooltipster._env.window;d.SVGElement&&c.origin instanceof d.SVGElement&&c.instance._plug(b)})}},instance:{__init:function(b){var c=this;if(c.__hadTitleTag=!1,c.__instance=b,!c.__instance._$origin.hasClass("tooltipstered")){var d=c.__instance._$origin.attr("class")||"";-1==d.indexOf("tooltipstered")&&c.__instance._$origin.attr("class",d+" tooltipstered")}if(null===c.__instance.content()){var e=c.__instance._$origin.find(">title");if(e[0]){var f=e.text();c.__hadTitleTag=!0,c.__instance._$origin.data("tooltipster-initialTitle",f),c.__instance.content(f),e.remove()}}c.__instance._on("geometry."+c.namespace,function(b){var c=a.tooltipster._env.window;if(c.SVG.svgjs){c.SVG.parser||c.SVG.prepare();var d=c.SVG.adopt(b.origin);if(d&&d.screenBBox){var e=d.screenBBox();b.edit({height:e.height,left:e.x,top:e.y,width:e.width})}}})._on("destroy."+c.namespace,function(){c.__destroy()})},__destroy:function(){var b=this;if(!b.__instance._$origin.hasClass("tooltipstered")){var c=b.__instance._$origin.attr("class").replace("tooltipstered","");b.__instance._$origin.attr("class",c)}b.__instance._off("."+b.namespace),b.__hadTitleTag&&b.__instance.one("destroyed",function(){var c=b.__instance._$origin.attr("title");c&&(a(document.createElementNS("http://www.w3.org/2000/svg","title")).text(c).appendTo(b.__instance._$origin),b.__instance._$origin.removeAttr("title"))})}}}),a})}); +\ No newline at end of file ++!function(a,b){void 0===a&&void 0!==window&&(a=window),"function"==typeof define&&define.amd?define(["tooltipster"],function(a){return b(a)}):"object"==typeof module&&module.exports?module.exports=b(require("tooltipster")):b(a.jQuery)}(this,function(a){!function(a,b){void 0===a&&void 0!==window&&(a=window),"function"==typeof define&&define.amd?define(["jquery"],function(a){return b(a)}):"object"==typeof module&&module.exports?module.exports=b(require("jquery")):b(a.jQuery)}(this,function(a){var b="tooltipster.SVG";return a.tooltipster._plugin({name:b,core:{__init:function(){a.tooltipster._on("init",function(c){var d=a.tooltipster._env.window;d.SVGElement&&c.origin instanceof d.SVGElement&&c.instance._plug(b)})}},instance:{__init:function(b){var c=this;if(c.__hadTitleTag=!1,c.__instance=b,!c.__instance._$origin.hasClass("tooltipstered")){var d=c.__instance._$origin.attr("class")||"";d.indexOf("tooltipstered")==-1&&c.__instance._$origin.attr("class",d+" tooltipstered")}if(null===c.__instance.content()){var e=c.__instance._$origin.find(">title");if(e[0]){var f=e.text();c.__hadTitleTag=!0,c.__instance._$origin.data("tooltipster-initialTitle",f),c.__instance.content(f),e.remove()}}c.__instance._on("geometry."+c.namespace,function(b){var c=a.tooltipster._env.window;if(c.SVG.svgjs){c.SVG.parser||c.SVG.prepare();var d=c.SVG.adopt(b.origin);if(d&&d.screenBBox){var e=d.screenBBox();b.edit({height:e.height,left:e.x,top:e.y,width:e.width})}}})._on("destroy."+c.namespace,function(){c.__destroy()})},__destroy:function(){var b=this;if(!b.__instance._$origin.hasClass("tooltipstered")){var c=b.__instance._$origin.attr("class").replace("tooltipstered","");b.__instance._$origin.attr("class",c)}b.__instance._off("."+b.namespace),b.__hadTitleTag&&b.__instance.one("destroyed",function(){var c=b.__instance._$origin.attr("title");c&&(a(document.createElementNS("http://www.w3.org/2000/svg","title")).text(c).appendTo(b.__instance._$origin),b.__instance._$origin.removeAttr("title"))})}}}),a})}); +\ No newline at end of file +diff --git a/node_modules/tooltipster/dist/js/tooltipster.bundle.js b/node_modules/tooltipster/dist/js/tooltipster.bundle.js +index ed97bdc..2b3381f 100644 +--- a/node_modules/tooltipster/dist/js/tooltipster.bundle.js ++++ b/node_modules/tooltipster/dist/js/tooltipster.bundle.js +@@ -5,18 +5,19 @@ + * MIT license + */ + (function (root, factory) { ++ if (root === undefined && window !== undefined) root = window; + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module unless amdModuleId is set + define(["jquery"], function (a0) { + return (factory(a0)); + }); +- } else if (typeof exports === 'object') { ++ } else if (typeof module === 'object' && module.exports) { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(require("jquery")); + } else { +- factory(jQuery); ++ factory(root["jQuery"]); + } + }(this, function ($) { + +@@ -1260,8 +1261,14 @@ $.Tooltipster.prototype = { + if ( geo.origin.windowOffset.top < bcr.top + || geo.origin.windowOffset.bottom > bcr.bottom + ) { +- overflows = true; +- return false; ++ if (self.__options.checkOverflowY) { ++ overflows = self.__options.checkOverflowY(geo, bcr); ++ } else { ++ overflows = true; ++ } ++ if (overflows) { ++ return false; ++ } + } + } + } +@@ -1412,7 +1419,7 @@ $.Tooltipster.prototype = { + + // close the tooltip when using the mouseleave close trigger + // (see https://github.com/iamceege/tooltipster/pull/253) +- if (self.__options.triggerClose.mouseleave) { ++ if (self.__options.triggerClose.mouseleave && !self.__options.ignoreCloseOnScroll) { + self._close(); + } + else { +@@ -3341,6 +3348,7 @@ function transitionSupport() { + // we'll return jQuery for plugins not to have to declare it as a dependency, + // but it's done by a build task since it should be included only once at the + // end when we concatenate the main file with a plugin ++ + // sideTip is Tooltipster's default plugin. + // This file will be UMDified by a build task. + +diff --git a/node_modules/tooltipster/dist/js/tooltipster.bundle.min.js b/node_modules/tooltipster/dist/js/tooltipster.bundle.min.js +index bbbd6ad..af0ffc0 100644 +--- a/node_modules/tooltipster/dist/js/tooltipster.bundle.min.js ++++ b/node_modules/tooltipster/dist/js/tooltipster.bundle.min.js +@@ -1,2 +1,2 @@ +-/*! tooltipster v4.2.8 */!function(a,b){"function"==typeof define&&define.amd?define(["jquery"],function(a){return b(a)}):"object"==typeof exports?module.exports=b(require("jquery")):b(jQuery)}(this,function(a){function b(a){this.$container,this.constraints=null,this.__$tooltip,this.__init(a)}function c(b,c){var d=!0;return a.each(b,function(a,e){return void 0===c[a]||b[a]!==c[a]?(d=!1,!1):void 0}),d}function d(b){var c=b.attr("id"),d=c?h.window.document.getElementById(c):null;return d?d===b[0]:a.contains(h.window.document.body,b[0])}function e(){if(!g)return!1;var a=g.document.body||g.document.documentElement,b=a.style,c="transition",d=["Moz","Webkit","Khtml","O","ms"];if("string"==typeof b[c])return!0;c=c.charAt(0).toUpperCase()+c.substr(1);for(var e=0;e0?e=c.__plugins[d]:a.each(c.__plugins,function(a,b){return b.name.substring(b.name.length-d.length-1)=="."+d?(e=b,!1):void 0}),e}if(b.name.indexOf(".")<0)throw new Error("Plugins must be namespaced");return c.__plugins[b.name]=b,b.core&&c.__bridge(b.core,c,b.name),this},_trigger:function(){var a=Array.prototype.slice.apply(arguments);return"string"==typeof a[0]&&(a[0]={type:a[0]}),this.__$emitterPrivate.trigger.apply(this.__$emitterPrivate,a),this.__$emitterPublic.trigger.apply(this.__$emitterPublic,a),this},instances:function(b){var c=[],d=b||".tooltipstered";return a(d).each(function(){var b=a(this),d=b.data("tooltipster-ns");d&&a.each(d,function(a,d){c.push(b.data(d))})}),c},instancesLatest:function(){return this.__instancesLatestArr},off:function(){return this.__$emitterPublic.off.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},on:function(){return this.__$emitterPublic.on.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},one:function(){return this.__$emitterPublic.one.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},origins:function(b){var c=b?b+" ":"";return a(c+".tooltipstered").toArray()},setDefaults:function(b){return a.extend(f,b),this},triggerHandler:function(){return this.__$emitterPublic.triggerHandler.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this}},a.tooltipster=new i,a.Tooltipster=function(b,c){this.__callbacks={close:[],open:[]},this.__closingTime,this.__Content,this.__contentBcr,this.__destroyed=!1,this.__$emitterPrivate=a({}),this.__$emitterPublic=a({}),this.__enabled=!0,this.__garbageCollector,this.__Geometry,this.__lastPosition,this.__namespace="tooltipster-"+Math.round(1e6*Math.random()),this.__options,this.__$originParents,this.__pointerIsOverOrigin=!1,this.__previousThemes=[],this.__state="closed",this.__timeouts={close:[],open:null},this.__touchEvents=[],this.__tracker=null,this._$origin,this._$tooltip,this.__init(b,c)},a.Tooltipster.prototype={__init:function(b,c){var d=this;if(d._$origin=a(b),d.__options=a.extend(!0,{},f,c),d.__optionsFormat(),!h.IE||h.IE>=d.__options.IEmin){var e=null;if(void 0===d._$origin.data("tooltipster-initialTitle")&&(e=d._$origin.attr("title"),void 0===e&&(e=null),d._$origin.data("tooltipster-initialTitle",e)),null!==d.__options.content)d.__contentSet(d.__options.content);else{var g,i=d._$origin.attr("data-tooltip-content");i&&(g=a(i)),g&&g[0]?d.__contentSet(g.first()):d.__contentSet(e)}d._$origin.removeAttr("title").addClass("tooltipstered"),d.__prepareOrigin(),d.__prepareGC(),a.each(d.__options.plugins,function(a,b){d._plug(b)}),h.hasTouchCapability&&a(h.window.document.body).on("touchmove."+d.__namespace+"-triggerOpen",function(a){d._touchRecordEvent(a)}),d._on("created",function(){d.__prepareTooltip()})._on("repositioned",function(a){d.__lastPosition=a.position})}else d.__options.disabled=!0},__contentInsert:function(){var a=this,b=a._$tooltip.find(".tooltipster-content"),c=a.__Content,d=function(a){c=a};return a._trigger({type:"format",content:a.__Content,format:d}),a.__options.functionFormat&&(c=a.__options.functionFormat.call(a,a,{origin:a._$origin[0]},a.__Content)),"string"!=typeof c||a.__options.contentAsHTML?b.empty().append(c):b.text(c),a},__contentSet:function(b){return b instanceof a&&this.__options.contentCloning&&(b=b.clone(!0)),this.__Content=b,this._trigger({type:"updated",content:b}),this},__destroyError:function(){throw new Error("This tooltip has been destroyed and cannot execute your method call.")},__geometry:function(){var b=this,c=b._$origin,d=b._$origin.is("area");if(d){var e=b._$origin.parent().attr("name");c=a('img[usemap="#'+e+'"]')}var f=c[0].getBoundingClientRect(),g=a(h.window.document),i=a(h.window),j=c,k={available:{document:null,window:null},document:{size:{height:g.height(),width:g.width()}},window:{scroll:{left:h.window.scrollX||h.window.document.documentElement.scrollLeft,top:h.window.scrollY||h.window.document.documentElement.scrollTop},size:{height:i.height(),width:i.width()}},origin:{fixedLineage:!1,offset:{},size:{height:f.bottom-f.top,width:f.right-f.left},usemapImage:d?c[0]:null,windowOffset:{bottom:f.bottom,left:f.left,right:f.right,top:f.top}}};if(d){var l=b._$origin.attr("shape"),m=b._$origin.attr("coords");if(m&&(m=m.split(","),a.map(m,function(a,b){m[b]=parseInt(a)})),"default"!=l)switch(l){case"circle":var n=m[0],o=m[1],p=m[2],q=o-p,r=n-p;k.origin.size.height=2*p,k.origin.size.width=k.origin.size.height,k.origin.windowOffset.left+=r,k.origin.windowOffset.top+=q;break;case"rect":var s=m[0],t=m[1],u=m[2],v=m[3];k.origin.size.height=v-t,k.origin.size.width=u-s,k.origin.windowOffset.left+=s,k.origin.windowOffset.top+=t;break;case"poly":for(var w=0,x=0,y=0,z=0,A="even",B=0;By&&(y=C,0===B&&(w=y)),w>C&&(w=C),A="odd"):(C>z&&(z=C,1==B&&(x=z)),x>C&&(x=C),A="even")}k.origin.size.height=z-x,k.origin.size.width=y-w,k.origin.windowOffset.left+=w,k.origin.windowOffset.top+=x}}var D=function(a){k.origin.size.height=a.height,k.origin.windowOffset.left=a.left,k.origin.windowOffset.top=a.top,k.origin.size.width=a.width};for(b._trigger({type:"geometry",edit:D,geometry:{height:k.origin.size.height,left:k.origin.windowOffset.left,top:k.origin.windowOffset.top,width:k.origin.size.width}}),k.origin.windowOffset.right=k.origin.windowOffset.left+k.origin.size.width,k.origin.windowOffset.bottom=k.origin.windowOffset.top+k.origin.size.height,k.origin.offset.left=k.origin.windowOffset.left+k.window.scroll.left,k.origin.offset.top=k.origin.windowOffset.top+k.window.scroll.top,k.origin.offset.bottom=k.origin.offset.top+k.origin.size.height,k.origin.offset.right=k.origin.offset.left+k.origin.size.width,k.available.document={bottom:{height:k.document.size.height-k.origin.offset.bottom,width:k.document.size.width},left:{height:k.document.size.height,width:k.origin.offset.left},right:{height:k.document.size.height,width:k.document.size.width-k.origin.offset.right},top:{height:k.origin.offset.top,width:k.document.size.width}},k.available.window={bottom:{height:Math.max(k.window.size.height-Math.max(k.origin.windowOffset.bottom,0),0),width:k.window.size.width},left:{height:k.window.size.height,width:Math.max(k.origin.windowOffset.left,0)},right:{height:k.window.size.height,width:Math.max(k.window.size.width-Math.max(k.origin.windowOffset.right,0),0)},top:{height:Math.max(k.origin.windowOffset.top,0),width:k.window.size.width}};"html"!=j[0].tagName.toLowerCase();){if("fixed"==j.css("position")){k.origin.fixedLineage=!0;break}j=j.parent()}return k},__optionsFormat:function(){return"number"==typeof this.__options.animationDuration&&(this.__options.animationDuration=[this.__options.animationDuration,this.__options.animationDuration]),"number"==typeof this.__options.delay&&(this.__options.delay=[this.__options.delay,this.__options.delay]),"number"==typeof this.__options.delayTouch&&(this.__options.delayTouch=[this.__options.delayTouch,this.__options.delayTouch]),"string"==typeof this.__options.theme&&(this.__options.theme=[this.__options.theme]),null===this.__options.parent?this.__options.parent=a(h.window.document.body):"string"==typeof this.__options.parent&&(this.__options.parent=a(this.__options.parent)),"hover"==this.__options.trigger?(this.__options.triggerOpen={mouseenter:!0,touchstart:!0},this.__options.triggerClose={mouseleave:!0,originClick:!0,touchleave:!0}):"click"==this.__options.trigger&&(this.__options.triggerOpen={click:!0,tap:!0},this.__options.triggerClose={click:!0,tap:!0}),this._trigger("options"),this},__prepareGC:function(){var b=this;return b.__options.selfDestruction?b.__garbageCollector=setInterval(function(){var c=(new Date).getTime();b.__touchEvents=a.grep(b.__touchEvents,function(a,b){return c-a.time>6e4}),d(b._$origin)||b.close(function(){b.destroy()})},2e4):clearInterval(b.__garbageCollector),b},__prepareOrigin:function(){var a=this;if(a._$origin.off("."+a.__namespace+"-triggerOpen"),h.hasTouchCapability&&a._$origin.on("touchstart."+a.__namespace+"-triggerOpen touchend."+a.__namespace+"-triggerOpen touchcancel."+a.__namespace+"-triggerOpen",function(b){a._touchRecordEvent(b)}),a.__options.triggerOpen.click||a.__options.triggerOpen.tap&&h.hasTouchCapability){var b="";a.__options.triggerOpen.click&&(b+="click."+a.__namespace+"-triggerOpen "),a.__options.triggerOpen.tap&&h.hasTouchCapability&&(b+="touchend."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){a._touchIsMeaningfulEvent(b)&&a._open(b)})}if(a.__options.triggerOpen.mouseenter||a.__options.triggerOpen.touchstart&&h.hasTouchCapability){var b="";a.__options.triggerOpen.mouseenter&&(b+="mouseenter."+a.__namespace+"-triggerOpen "),a.__options.triggerOpen.touchstart&&h.hasTouchCapability&&(b+="touchstart."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){!a._touchIsTouchEvent(b)&&a._touchIsEmulatedEvent(b)||(a.__pointerIsOverOrigin=!0,a._openShortly(b))})}if(a.__options.triggerClose.mouseleave||a.__options.triggerClose.touchleave&&h.hasTouchCapability){var b="";a.__options.triggerClose.mouseleave&&(b+="mouseleave."+a.__namespace+"-triggerOpen "),a.__options.triggerClose.touchleave&&h.hasTouchCapability&&(b+="touchend."+a.__namespace+"-triggerOpen touchcancel."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){a._touchIsMeaningfulEvent(b)&&(a.__pointerIsOverOrigin=!1)})}return a},__prepareTooltip:function(){var b=this,c=b.__options.interactive?"auto":"";return b._$tooltip.attr("id",b.__namespace).css({"pointer-events":c,zIndex:b.__options.zIndex}),a.each(b.__previousThemes,function(a,c){b._$tooltip.removeClass(c)}),a.each(b.__options.theme,function(a,c){b._$tooltip.addClass(c)}),b.__previousThemes=a.merge([],b.__options.theme),b},__scrollHandler:function(b){var c=this;if(c.__options.triggerClose.scroll)c._close(b);else if(d(c._$origin)&&d(c._$tooltip)){var e=null;if(b.target===h.window.document)c.__Geometry.origin.fixedLineage||c.__options.repositionOnScroll&&c.reposition(b);else{e=c.__geometry();var f=!1;if("fixed"!=c._$origin.css("position")&&c.__$originParents.each(function(b,c){var d=a(c),g=d.css("overflow-x"),h=d.css("overflow-y");if("visible"!=g||"visible"!=h){var i=c.getBoundingClientRect();if("visible"!=g&&(e.origin.windowOffset.lefti.right))return f=!0,!1;if("visible"!=h&&(e.origin.windowOffset.topi.bottom))return f=!0,!1}return"fixed"==d.css("position")?!1:void 0}),f)c._$tooltip.css("visibility","hidden");else if(c._$tooltip.css("visibility","visible"),c.__options.repositionOnScroll)c.reposition(b);else{var g=e.origin.offset.left-c.__Geometry.origin.offset.left,i=e.origin.offset.top-c.__Geometry.origin.offset.top;c._$tooltip.css({left:c.__lastPosition.coord.left+g,top:c.__lastPosition.coord.top+i})}}c._trigger({type:"scroll",event:b,geo:e})}return c},__stateSet:function(a){return this.__state=a,this._trigger({type:"state",state:a}),this},__timeoutsClear:function(){return clearTimeout(this.__timeouts.open),this.__timeouts.open=null,a.each(this.__timeouts.close,function(a,b){clearTimeout(b)}),this.__timeouts.close=[],this},__trackerStart:function(){var a=this,b=a._$tooltip.find(".tooltipster-content");return a.__options.trackTooltip&&(a.__contentBcr=b[0].getBoundingClientRect()),a.__tracker=setInterval(function(){if(d(a._$origin)&&d(a._$tooltip)){if(a.__options.trackOrigin){var e=a.__geometry(),f=!1;c(e.origin.size,a.__Geometry.origin.size)&&(a.__Geometry.origin.fixedLineage?c(e.origin.windowOffset,a.__Geometry.origin.windowOffset)&&(f=!0):c(e.origin.offset,a.__Geometry.origin.offset)&&(f=!0)),f||(a.__options.triggerClose.mouseleave?a._close():a.reposition())}if(a.__options.trackTooltip){var g=b[0].getBoundingClientRect();g.height===a.__contentBcr.height&&g.width===a.__contentBcr.width||(a.reposition(),a.__contentBcr=g)}}else a._close()},a.__options.trackerInterval),a},_close:function(b,c,d){var e=this,f=!0;if(e._trigger({type:"close",event:b,stop:function(){f=!1}}),f||d){c&&e.__callbacks.close.push(c),e.__callbacks.open=[],e.__timeoutsClear();var g=function(){a.each(e.__callbacks.close,function(a,c){c.call(e,e,{event:b,origin:e._$origin[0]})}),e.__callbacks.close=[]};if("closed"!=e.__state){var i=!0,j=new Date,k=j.getTime(),l=k+e.__options.animationDuration[1];if("disappearing"==e.__state&&l>e.__closingTime&&e.__options.animationDuration[1]>0&&(i=!1),i){e.__closingTime=l,"disappearing"!=e.__state&&e.__stateSet("disappearing");var m=function(){clearInterval(e.__tracker),e._trigger({type:"closing",event:b}),e._$tooltip.off("."+e.__namespace+"-triggerClose").removeClass("tooltipster-dying"),a(h.window).off("."+e.__namespace+"-triggerClose"),e.__$originParents.each(function(b,c){a(c).off("scroll."+e.__namespace+"-triggerClose")}),e.__$originParents=null,a(h.window.document.body).off("."+e.__namespace+"-triggerClose"),e._$origin.off("."+e.__namespace+"-triggerClose"),e._off("dismissable"),e.__stateSet("closed"),e._trigger({type:"after",event:b}),e.__options.functionAfter&&e.__options.functionAfter.call(e,e,{event:b,origin:e._$origin[0]}),g()};h.hasTransitions?(e._$tooltip.css({"-moz-animation-duration":e.__options.animationDuration[1]+"ms","-ms-animation-duration":e.__options.animationDuration[1]+"ms","-o-animation-duration":e.__options.animationDuration[1]+"ms","-webkit-animation-duration":e.__options.animationDuration[1]+"ms","animation-duration":e.__options.animationDuration[1]+"ms","transition-duration":e.__options.animationDuration[1]+"ms"}),e._$tooltip.clearQueue().removeClass("tooltipster-show").addClass("tooltipster-dying"),e.__options.animationDuration[1]>0&&e._$tooltip.delay(e.__options.animationDuration[1]),e._$tooltip.queue(m)):e._$tooltip.stop().fadeOut(e.__options.animationDuration[1],m)}}else g()}return e},_off:function(){return this.__$emitterPrivate.off.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_on:function(){return this.__$emitterPrivate.on.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_one:function(){return this.__$emitterPrivate.one.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_open:function(b,c){var e=this;if(!e.__destroying&&d(e._$origin)&&e.__enabled){var f=!0;if("closed"==e.__state&&(e._trigger({type:"before",event:b,stop:function(){f=!1}}),f&&e.__options.functionBefore&&(f=e.__options.functionBefore.call(e,e,{event:b,origin:e._$origin[0]}))),f!==!1&&null!==e.__Content){c&&e.__callbacks.open.push(c),e.__callbacks.close=[],e.__timeoutsClear();var g,i=function(){"stable"!=e.__state&&e.__stateSet("stable"),a.each(e.__callbacks.open,function(a,b){b.call(e,e,{origin:e._$origin[0],tooltip:e._$tooltip[0]})}),e.__callbacks.open=[]};if("closed"!==e.__state)g=0,"disappearing"===e.__state?(e.__stateSet("appearing"),h.hasTransitions?(e._$tooltip.clearQueue().removeClass("tooltipster-dying").addClass("tooltipster-show"),e.__options.animationDuration[0]>0&&e._$tooltip.delay(e.__options.animationDuration[0]),e._$tooltip.queue(i)):e._$tooltip.stop().fadeIn(i)):"stable"==e.__state&&i();else{if(e.__stateSet("appearing"),g=e.__options.animationDuration[0],e.__contentInsert(),e.reposition(b,!0),h.hasTransitions?(e._$tooltip.addClass("tooltipster-"+e.__options.animation).addClass("tooltipster-initial").css({"-moz-animation-duration":e.__options.animationDuration[0]+"ms","-ms-animation-duration":e.__options.animationDuration[0]+"ms","-o-animation-duration":e.__options.animationDuration[0]+"ms","-webkit-animation-duration":e.__options.animationDuration[0]+"ms","animation-duration":e.__options.animationDuration[0]+"ms","transition-duration":e.__options.animationDuration[0]+"ms"}),setTimeout(function(){"closed"!=e.__state&&(e._$tooltip.addClass("tooltipster-show").removeClass("tooltipster-initial"),e.__options.animationDuration[0]>0&&e._$tooltip.delay(e.__options.animationDuration[0]),e._$tooltip.queue(i))},0)):e._$tooltip.css("display","none").fadeIn(e.__options.animationDuration[0],i),e.__trackerStart(),a(h.window).on("resize."+e.__namespace+"-triggerClose",function(b){var c=a(document.activeElement);(c.is("input")||c.is("textarea"))&&a.contains(e._$tooltip[0],c[0])||e.reposition(b)}).on("scroll."+e.__namespace+"-triggerClose",function(a){e.__scrollHandler(a)}),e.__$originParents=e._$origin.parents(),e.__$originParents.each(function(b,c){a(c).on("scroll."+e.__namespace+"-triggerClose",function(a){e.__scrollHandler(a)})}),e.__options.triggerClose.mouseleave||e.__options.triggerClose.touchleave&&h.hasTouchCapability){e._on("dismissable",function(a){a.dismissable?a.delay?(m=setTimeout(function(){e._close(a.event)},a.delay),e.__timeouts.close.push(m)):e._close(a):clearTimeout(m)});var j=e._$origin,k="",l="",m=null;e.__options.interactive&&(j=j.add(e._$tooltip)),e.__options.triggerClose.mouseleave&&(k+="mouseenter."+e.__namespace+"-triggerClose ",l+="mouseleave."+e.__namespace+"-triggerClose "),e.__options.triggerClose.touchleave&&h.hasTouchCapability&&(k+="touchstart."+e.__namespace+"-triggerClose",l+="touchend."+e.__namespace+"-triggerClose touchcancel."+e.__namespace+"-triggerClose"),j.on(l,function(a){if(e._touchIsTouchEvent(a)||!e._touchIsEmulatedEvent(a)){var b="mouseleave"==a.type?e.__options.delay:e.__options.delayTouch;e._trigger({delay:b[1],dismissable:!0,event:a,type:"dismissable"})}}).on(k,function(a){!e._touchIsTouchEvent(a)&&e._touchIsEmulatedEvent(a)||e._trigger({dismissable:!1,event:a,type:"dismissable"})})}e.__options.triggerClose.originClick&&e._$origin.on("click."+e.__namespace+"-triggerClose",function(a){e._touchIsTouchEvent(a)||e._touchIsEmulatedEvent(a)||e._close(a)}),(e.__options.triggerClose.click||e.__options.triggerClose.tap&&h.hasTouchCapability)&&setTimeout(function(){if("closed"!=e.__state){var b="",c=a(h.window.document.body);e.__options.triggerClose.click&&(b+="click."+e.__namespace+"-triggerClose "),e.__options.triggerClose.tap&&h.hasTouchCapability&&(b+="touchend."+e.__namespace+"-triggerClose"),c.on(b,function(b){e._touchIsMeaningfulEvent(b)&&(e._touchRecordEvent(b),e.__options.interactive&&a.contains(e._$tooltip[0],b.target)||e._close(b))}),e.__options.triggerClose.tap&&h.hasTouchCapability&&c.on("touchstart."+e.__namespace+"-triggerClose",function(a){e._touchRecordEvent(a)})}},0),e._trigger("ready"),e.__options.functionReady&&e.__options.functionReady.call(e,e,{origin:e._$origin[0],tooltip:e._$tooltip[0]})}if(e.__options.timer>0){var m=setTimeout(function(){e._close()},e.__options.timer+g);e.__timeouts.close.push(m)}}}return e},_openShortly:function(a){var b=this,c=!0;if("stable"!=b.__state&&"appearing"!=b.__state&&!b.__timeouts.open&&(b._trigger({type:"start",event:a,stop:function(){c=!1}}),c)){var d=0==a.type.indexOf("touch")?b.__options.delayTouch:b.__options.delay;d[0]?b.__timeouts.open=setTimeout(function(){b.__timeouts.open=null,b.__pointerIsOverOrigin&&b._touchIsMeaningfulEvent(a)?(b._trigger("startend"),b._open(a)):b._trigger("startcancel")},d[0]):(b._trigger("startend"),b._open(a))}return b},_optionsExtract:function(b,c){var d=this,e=a.extend(!0,{},c),f=d.__options[b];return f||(f={},a.each(c,function(a,b){var c=d.__options[a];void 0!==c&&(f[a]=c)})),a.each(e,function(b,c){void 0!==f[b]&&("object"!=typeof c||c instanceof Array||null==c||"object"!=typeof f[b]||f[b]instanceof Array||null==f[b]?e[b]=f[b]:a.extend(e[b],f[b]))}),e},_plug:function(b){var c=a.tooltipster._plugin(b);if(!c)throw new Error('The "'+b+'" plugin is not defined');return c.instance&&a.tooltipster.__bridge(c.instance,this,c.name),this},_touchIsEmulatedEvent:function(a){for(var b=!1,c=(new Date).getTime(),d=this.__touchEvents.length-1;d>=0;d--){var e=this.__touchEvents[d];if(!(c-e.time<500))break;e.target===a.target&&(b=!0)}return b},_touchIsMeaningfulEvent:function(a){return this._touchIsTouchEvent(a)&&!this._touchSwiped(a.target)||!this._touchIsTouchEvent(a)&&!this._touchIsEmulatedEvent(a)},_touchIsTouchEvent:function(a){return 0==a.type.indexOf("touch")},_touchRecordEvent:function(a){return this._touchIsTouchEvent(a)&&(a.time=(new Date).getTime(),this.__touchEvents.push(a)),this},_touchSwiped:function(a){for(var b=!1,c=this.__touchEvents.length-1;c>=0;c--){var d=this.__touchEvents[c];if("touchmove"==d.type){b=!0;break}if("touchstart"==d.type&&a===d.target)break}return b},_trigger:function(){var b=Array.prototype.slice.apply(arguments);return"string"==typeof b[0]&&(b[0]={type:b[0]}),b[0].instance=this,b[0].origin=this._$origin?this._$origin[0]:null,b[0].tooltip=this._$tooltip?this._$tooltip[0]:null,this.__$emitterPrivate.trigger.apply(this.__$emitterPrivate,b),a.tooltipster._trigger.apply(a.tooltipster,b),this.__$emitterPublic.trigger.apply(this.__$emitterPublic,b),this},_unplug:function(b){var c=this;if(c[b]){var d=a.tooltipster._plugin(b);d.instance&&a.each(d.instance,function(a,d){c[a]&&c[a].bridged===c[b]&&delete c[a]}),c[b].__destroy&&c[b].__destroy(),delete c[b]}return c},close:function(a){return this.__destroyed?this.__destroyError():this._close(null,a),this},content:function(a){var b=this;if(void 0===a)return b.__Content;if(b.__destroyed)b.__destroyError();else if(b.__contentSet(a),null!==b.__Content){if("closed"!==b.__state&&(b.__contentInsert(),b.reposition(),b.__options.updateAnimation))if(h.hasTransitions){var c=b.__options.updateAnimation;b._$tooltip.addClass("tooltipster-update-"+c),setTimeout(function(){"closed"!=b.__state&&b._$tooltip.removeClass("tooltipster-update-"+c)},1e3)}else b._$tooltip.fadeTo(200,.5,function(){"closed"!=b.__state&&b._$tooltip.fadeTo(200,1)})}else b._close();return b},destroy:function(){var b=this;if(b.__destroyed)b.__destroyError();else{"closed"!=b.__state?b.option("animationDuration",0)._close(null,null,!0):b.__timeoutsClear(),b._trigger("destroy"),b.__destroyed=!0,b._$origin.removeData(b.__namespace).off("."+b.__namespace+"-triggerOpen"),a(h.window.document.body).off("."+b.__namespace+"-triggerOpen");var c=b._$origin.data("tooltipster-ns");if(c)if(1===c.length){var d=null;"previous"==b.__options.restoration?d=b._$origin.data("tooltipster-initialTitle"):"current"==b.__options.restoration&&(d="string"==typeof b.__Content?b.__Content:a("
    ").append(b.__Content).html()),d&&b._$origin.attr("title",d),b._$origin.removeClass("tooltipstered"),b._$origin.removeData("tooltipster-ns").removeData("tooltipster-initialTitle")}else c=a.grep(c,function(a,c){return a!==b.__namespace}),b._$origin.data("tooltipster-ns",c);b._trigger("destroyed"),b._off(),b.off(),b.__Content=null,b.__$emitterPrivate=null,b.__$emitterPublic=null,b.__options.parent=null,b._$origin=null,b._$tooltip=null,a.tooltipster.__instancesLatestArr=a.grep(a.tooltipster.__instancesLatestArr,function(a,c){return b!==a}),clearInterval(b.__garbageCollector)}return b},disable:function(){return this.__destroyed?(this.__destroyError(),this):(this._close(),this.__enabled=!1,this)},elementOrigin:function(){return this.__destroyed?void this.__destroyError():this._$origin[0]},elementTooltip:function(){return this._$tooltip?this._$tooltip[0]:null},enable:function(){return this.__enabled=!0,this},hide:function(a){return this.close(a)},instance:function(){return this},off:function(){return this.__destroyed||this.__$emitterPublic.off.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},on:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.on.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},one:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.one.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},open:function(a){return this.__destroyed?this.__destroyError():this._open(null,a),this},option:function(b,c){return void 0===c?this.__options[b]:(this.__destroyed?this.__destroyError():(this.__options[b]=c,this.__optionsFormat(),a.inArray(b,["trigger","triggerClose","triggerOpen"])>=0&&this.__prepareOrigin(),"selfDestruction"===b&&this.__prepareGC()),this)},reposition:function(a,b){var c=this;return c.__destroyed?c.__destroyError():"closed"!=c.__state&&d(c._$origin)&&(b||d(c._$tooltip))&&(b||c._$tooltip.detach(),c.__Geometry=c.__geometry(),c._trigger({type:"reposition",event:a,helper:{geo:c.__Geometry}})),c},show:function(a){return this.open(a)},status:function(){return{destroyed:this.__destroyed,enabled:this.__enabled,open:"closed"!==this.__state,state:this.__state}},triggerHandler:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.triggerHandler.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this}},a.fn.tooltipster=function(){var b=Array.prototype.slice.apply(arguments),c="You are using a single HTML element as content for several tooltips. You probably want to set the contentCloning option to TRUE.";if(0===this.length)return this;if("string"==typeof b[0]){var d="#*$~&";return this.each(function(){var e=a(this).data("tooltipster-ns"),f=e?a(this).data(e[0]):null;if(!f)throw new Error("You called Tooltipster's \""+b[0]+'" method on an uninitialized element');if("function"!=typeof f[b[0]])throw new Error('Unknown method "'+b[0]+'"');this.length>1&&"content"==b[0]&&(b[1]instanceof a||"object"==typeof b[1]&&null!=b[1]&&b[1].tagName)&&!f.__options.contentCloning&&f.__options.debug&&console.log(c);var g=f[b[0]](b[1],b[2]);return g!==f||"instance"===b[0]?(d=g,!1):void 0}),"#*$~&"!==d?d:this}a.tooltipster.__instancesLatestArr=[];var e=b[0]&&void 0!==b[0].multiple,g=e&&b[0].multiple||!e&&f.multiple,h=b[0]&&void 0!==b[0].content,i=h&&b[0].content||!h&&f.content,j=b[0]&&void 0!==b[0].contentCloning,k=j&&b[0].contentCloning||!j&&f.contentCloning,l=b[0]&&void 0!==b[0].debug,m=l&&b[0].debug||!l&&f.debug;return this.length>1&&(i instanceof a||"object"==typeof i&&null!=i&&i.tagName)&&!k&&m&&console.log(c),this.each(function(){var c=!1,d=a(this),e=d.data("tooltipster-ns"),f=null;e?g?c=!0:m&&(console.log("Tooltipster: one or more tooltips are already attached to the element below. Ignoring."),console.log(this)):c=!0,c&&(f=new a.Tooltipster(this,b[0]),e||(e=[]),e.push(f.__namespace),d.data("tooltipster-ns",e),d.data(f.__namespace,f),f.__options.functionInit&&f.__options.functionInit.call(f,f,{origin:this}),f._trigger("init")),a.tooltipster.__instancesLatestArr.push(f)}),this},b.prototype={__init:function(b){this.__$tooltip=b,this.__$tooltip.css({left:0,overflow:"hidden",position:"absolute",top:0}).find(".tooltipster-content").css("overflow","auto"),this.$container=a('
    ').append(this.__$tooltip).appendTo(h.window.document.body)},__forceRedraw:function(){var a=this.__$tooltip.parent();this.__$tooltip.detach(),this.__$tooltip.appendTo(a)},constrain:function(a,b){return this.constraints={width:a,height:b},this.__$tooltip.css({display:"block",height:"",overflow:"auto",width:a}),this},destroy:function(){this.__$tooltip.detach().find(".tooltipster-content").css({display:"",overflow:""}),this.$container.remove()},free:function(){return this.constraints=null,this.__$tooltip.css({display:"",height:"",overflow:"visible",width:""}),this},measure:function(){this.__forceRedraw();var a=this.__$tooltip[0].getBoundingClientRect(),b={size:{height:a.height||a.bottom-a.top,width:a.width||a.right-a.left}};if(this.constraints){var c=this.__$tooltip.find(".tooltipster-content"),d=this.__$tooltip.outerHeight(),e=c[0].getBoundingClientRect(),f={height:d<=this.constraints.height,width:a.width<=this.constraints.width&&e.width>=c[0].scrollWidth-1};b.fits=f.height&&f.width}return h.IE&&h.IE<=11&&b.size.width!==h.window.document.documentElement.clientWidth&&(b.size.width=Math.ceil(b.size.width)+1),b}};var j=navigator.userAgent.toLowerCase();-1!=j.indexOf("msie")?h.IE=parseInt(j.split("msie")[1]):-1!==j.toLowerCase().indexOf("trident")&&-1!==j.indexOf(" rv:11")?h.IE=11:-1!=j.toLowerCase().indexOf("edge/")&&(h.IE=parseInt(j.toLowerCase().split("edge/")[1]));var k="tooltipster.sideTip";return a.tooltipster._plugin({name:k,instance:{__defaults:function(){return{arrow:!0,distance:6,functionPosition:null,maxWidth:null,minIntersection:16,minWidth:0,position:null,side:"top",viewportAware:!0}},__init:function(a){var b=this;b.__instance=a,b.__namespace="tooltipster-sideTip-"+Math.round(1e6*Math.random()),b.__previousState="closed",b.__options,b.__optionsFormat(),b.__instance._on("state."+b.__namespace,function(a){"closed"==a.state?b.__close():"appearing"==a.state&&"closed"==b.__previousState&&b.__create(),b.__previousState=a.state}),b.__instance._on("options."+b.__namespace,function(){b.__optionsFormat()}),b.__instance._on("reposition."+b.__namespace,function(a){b.__reposition(a.event,a.helper)})},__close:function(){this.__instance.content()instanceof a&&this.__instance.content().detach(),this.__instance._$tooltip.remove(),this.__instance._$tooltip=null},__create:function(){var b=a('
    ');this.__options.arrow||b.find(".tooltipster-box").css("margin",0).end().find(".tooltipster-arrow").hide(),this.__options.minWidth&&b.css("min-width",this.__options.minWidth+"px"),this.__options.maxWidth&&b.css("max-width",this.__options.maxWidth+"px"), +-this.__instance._$tooltip=b,this.__instance._trigger("created")},__destroy:function(){this.__instance._off("."+self.__namespace)},__optionsFormat:function(){var b=this;if(b.__options=b.__instance._optionsExtract(k,b.__defaults()),b.__options.position&&(b.__options.side=b.__options.position),"object"!=typeof b.__options.distance&&(b.__options.distance=[b.__options.distance]),b.__options.distance.length<4&&(void 0===b.__options.distance[1]&&(b.__options.distance[1]=b.__options.distance[0]),void 0===b.__options.distance[2]&&(b.__options.distance[2]=b.__options.distance[0]),void 0===b.__options.distance[3]&&(b.__options.distance[3]=b.__options.distance[1])),b.__options.distance={top:b.__options.distance[0],right:b.__options.distance[1],bottom:b.__options.distance[2],left:b.__options.distance[3]},"string"==typeof b.__options.side){var c={top:"bottom",right:"left",bottom:"top",left:"right"};b.__options.side=[b.__options.side,c[b.__options.side]],"left"==b.__options.side[0]||"right"==b.__options.side[0]?b.__options.side.push("top","bottom"):b.__options.side.push("right","left")}6===a.tooltipster._env.IE&&b.__options.arrow!==!0&&(b.__options.arrow=!1)},__reposition:function(b,c){var d,e=this,f=e.__targetFind(c),g=[];e.__instance._$tooltip.detach();var h=e.__instance._$tooltip.clone(),i=a.tooltipster._getRuler(h),j=!1,k=e.__instance.option("animation");switch(k&&h.removeClass("tooltipster-"+k),a.each(["window","document"],function(d,k){var l=null;if(e.__instance._trigger({container:k,helper:c,satisfied:j,takeTest:function(a){l=a},results:g,type:"positionTest"}),1==l||0!=l&&0==j&&("window"!=k||e.__options.viewportAware))for(var d=0;d=h.outerSize.width&&c.geo.available[k][n].height>=h.outerSize.height?h.fits=!0:h.fits=!1:h.fits=p.fits,"window"==k&&(h.fits?"top"==n||"bottom"==n?h.whole=c.geo.origin.windowOffset.right>=e.__options.minIntersection&&c.geo.window.size.width-c.geo.origin.windowOffset.left>=e.__options.minIntersection:h.whole=c.geo.origin.windowOffset.bottom>=e.__options.minIntersection&&c.geo.window.size.height-c.geo.origin.windowOffset.top>=e.__options.minIntersection:h.whole=!1),g.push(h),h.whole)j=!0;else if("natural"==h.mode&&(h.fits||h.size.width<=c.geo.available[k][n].width))return!1}})}}),e.__instance._trigger({edit:function(a){g=a},event:b,helper:c,results:g,type:"positionTested"}),g.sort(function(a,b){if(a.whole&&!b.whole)return-1;if(!a.whole&&b.whole)return 1;if(a.whole&&b.whole){var c=e.__options.side.indexOf(a.side),d=e.__options.side.indexOf(b.side);return d>c?-1:c>d?1:"natural"==a.mode?-1:1}if(a.fits&&!b.fits)return-1;if(!a.fits&&b.fits)return 1;if(a.fits&&b.fits){var c=e.__options.side.indexOf(a.side),d=e.__options.side.indexOf(b.side);return d>c?-1:c>d?1:"natural"==a.mode?-1:1}return"document"==a.container&&"bottom"==a.side&&"natural"==a.mode?-1:1}),d=g[0],d.coord={},d.side){case"left":case"right":d.coord.top=Math.floor(d.target-d.size.height/2);break;case"bottom":case"top":d.coord.left=Math.floor(d.target-d.size.width/2)}switch(d.side){case"left":d.coord.left=c.geo.origin.windowOffset.left-d.outerSize.width;break;case"right":d.coord.left=c.geo.origin.windowOffset.right+d.distance.horizontal;break;case"top":d.coord.top=c.geo.origin.windowOffset.top-d.outerSize.height;break;case"bottom":d.coord.top=c.geo.origin.windowOffset.bottom+d.distance.vertical}"window"==d.container?"top"==d.side||"bottom"==d.side?d.coord.left<0?c.geo.origin.windowOffset.right-this.__options.minIntersection>=0?d.coord.left=0:d.coord.left=c.geo.origin.windowOffset.right-this.__options.minIntersection-1:d.coord.left>c.geo.window.size.width-d.size.width&&(c.geo.origin.windowOffset.left+this.__options.minIntersection<=c.geo.window.size.width?d.coord.left=c.geo.window.size.width-d.size.width:d.coord.left=c.geo.origin.windowOffset.left+this.__options.minIntersection+1-d.size.width):d.coord.top<0?c.geo.origin.windowOffset.bottom-this.__options.minIntersection>=0?d.coord.top=0:d.coord.top=c.geo.origin.windowOffset.bottom-this.__options.minIntersection-1:d.coord.top>c.geo.window.size.height-d.size.height&&(c.geo.origin.windowOffset.top+this.__options.minIntersection<=c.geo.window.size.height?d.coord.top=c.geo.window.size.height-d.size.height:d.coord.top=c.geo.origin.windowOffset.top+this.__options.minIntersection+1-d.size.height):(d.coord.left>c.geo.window.size.width-d.size.width&&(d.coord.left=c.geo.window.size.width-d.size.width),d.coord.left<0&&(d.coord.left=0)),e.__sideChange(h,d.side),c.tooltipClone=h[0],c.tooltipParent=e.__instance.option("parent").parent[0],c.mode=d.mode,c.whole=d.whole,c.origin=e.__instance._$origin[0],c.tooltip=e.__instance._$tooltip[0],delete d.container,delete d.fits,delete d.mode,delete d.outerSize,delete d.whole,d.distance=d.distance.horizontal||d.distance.vertical;var l=a.extend(!0,{},d);if(e.__instance._trigger({edit:function(a){d=a},event:b,helper:c,position:l,type:"position"}),e.__options.functionPosition){var m=e.__options.functionPosition.call(e,e.__instance,c,l);m&&(d=m)}i.destroy();var n,o;"top"==d.side||"bottom"==d.side?(n={prop:"left",val:d.target-d.coord.left},o=d.size.width-this.__options.minIntersection):(n={prop:"top",val:d.target-d.coord.top},o=d.size.height-this.__options.minIntersection),n.valo&&(n.val=o);var p;p=c.geo.origin.fixedLineage?c.geo.origin.windowOffset:{left:c.geo.origin.windowOffset.left+c.geo.window.scroll.left,top:c.geo.origin.windowOffset.top+c.geo.window.scroll.top},d.coord={left:p.left+(d.coord.left-c.geo.origin.windowOffset.left),top:p.top+(d.coord.top-c.geo.origin.windowOffset.top)},e.__sideChange(e.__instance._$tooltip,d.side),c.geo.origin.fixedLineage?e.__instance._$tooltip.css("position","fixed"):e.__instance._$tooltip.css("position",""),e.__instance._$tooltip.css({left:d.coord.left,top:d.coord.top,height:d.size.height,width:d.size.width}).find(".tooltipster-arrow").css({left:"",top:""}).css(n.prop,n.val),e.__instance._$tooltip.appendTo(e.__instance.option("parent")),e.__instance._trigger({type:"repositioned",event:b,position:d})},__sideChange:function(a,b){a.removeClass("tooltipster-bottom").removeClass("tooltipster-left").removeClass("tooltipster-right").removeClass("tooltipster-top").addClass("tooltipster-"+b)},__targetFind:function(a){var b={},c=this.__instance._$origin[0].getClientRects();if(c.length>1){var d=this.__instance._$origin.css("opacity");1==d&&(this.__instance._$origin.css("opacity",.99),c=this.__instance._$origin[0].getClientRects(),this.__instance._$origin.css("opacity",1))}if(c.length<2)b.top=Math.floor(a.geo.origin.windowOffset.left+a.geo.origin.size.width/2),b.bottom=b.top,b.left=Math.floor(a.geo.origin.windowOffset.top+a.geo.origin.size.height/2),b.right=b.left;else{var e=c[0];b.top=Math.floor(e.left+(e.right-e.left)/2),e=c.length>2?c[Math.ceil(c.length/2)-1]:c[0],b.right=Math.floor(e.top+(e.bottom-e.top)/2),e=c[c.length-1],b.bottom=Math.floor(e.left+(e.right-e.left)/2),e=c.length>2?c[Math.ceil((c.length+1)/2)-1]:c[c.length-1],b.left=Math.floor(e.top+(e.bottom-e.top)/2)}return b}}}),a}); +\ No newline at end of file ++/*! tooltipster v4.2.8 */!function(a,b){void 0===a&&void 0!==window&&(a=window),"function"==typeof define&&define.amd?define(["jquery"],function(a){return b(a)}):"object"==typeof module&&module.exports?module.exports=b(require("jquery")):b(a.jQuery)}(this,function(a){function b(a){this.$container,this.constraints=null,this.__$tooltip,this.__init(a)}function c(b,c){var d=!0;return a.each(b,function(a,e){if(void 0===c[a]||b[a]!==c[a])return d=!1,!1}),d}function d(b){var c=b.attr("id"),d=c?h.window.document.getElementById(c):null;return d?d===b[0]:a.contains(h.window.document.body,b[0])}function e(){if(!g)return!1;var a=g.document.body||g.document.documentElement,b=a.style,c="transition",d=["Moz","Webkit","Khtml","O","ms"];if("string"==typeof b[c])return!0;c=c.charAt(0).toUpperCase()+c.substr(1);for(var e=0;e0?e=c.__plugins[d]:a.each(c.__plugins,function(a,b){if(b.name.substring(b.name.length-d.length-1)=="."+d)return e=b,!1}),e}if(b.name.indexOf(".")<0)throw new Error("Plugins must be namespaced");return c.__plugins[b.name]=b,b.core&&c.__bridge(b.core,c,b.name),this},_trigger:function(){var a=Array.prototype.slice.apply(arguments);return"string"==typeof a[0]&&(a[0]={type:a[0]}),this.__$emitterPrivate.trigger.apply(this.__$emitterPrivate,a),this.__$emitterPublic.trigger.apply(this.__$emitterPublic,a),this},instances:function(b){var c=[],d=b||".tooltipstered";return a(d).each(function(){var b=a(this),d=b.data("tooltipster-ns");d&&a.each(d,function(a,d){c.push(b.data(d))})}),c},instancesLatest:function(){return this.__instancesLatestArr},off:function(){return this.__$emitterPublic.off.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},on:function(){return this.__$emitterPublic.on.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},one:function(){return this.__$emitterPublic.one.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},origins:function(b){var c=b?b+" ":"";return a(c+".tooltipstered").toArray()},setDefaults:function(b){return a.extend(f,b),this},triggerHandler:function(){return this.__$emitterPublic.triggerHandler.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this}},a.tooltipster=new i,a.Tooltipster=function(b,c){this.__callbacks={close:[],open:[]},this.__closingTime,this.__Content,this.__contentBcr,this.__destroyed=!1,this.__$emitterPrivate=a({}),this.__$emitterPublic=a({}),this.__enabled=!0,this.__garbageCollector,this.__Geometry,this.__lastPosition,this.__namespace="tooltipster-"+Math.round(1e6*Math.random()),this.__options,this.__$originParents,this.__pointerIsOverOrigin=!1,this.__previousThemes=[],this.__state="closed",this.__timeouts={close:[],open:null},this.__touchEvents=[],this.__tracker=null,this._$origin,this._$tooltip,this.__init(b,c)},a.Tooltipster.prototype={__init:function(b,c){var d=this;if(d._$origin=a(b),d.__options=a.extend(!0,{},f,c),d.__optionsFormat(),!h.IE||h.IE>=d.__options.IEmin){var e=null;if(void 0===d._$origin.data("tooltipster-initialTitle")&&(e=d._$origin.attr("title"),void 0===e&&(e=null),d._$origin.data("tooltipster-initialTitle",e)),null!==d.__options.content)d.__contentSet(d.__options.content);else{var g,i=d._$origin.attr("data-tooltip-content");i&&(g=a(i)),g&&g[0]?d.__contentSet(g.first()):d.__contentSet(e)}d._$origin.removeAttr("title").addClass("tooltipstered"),d.__prepareOrigin(),d.__prepareGC(),a.each(d.__options.plugins,function(a,b){d._plug(b)}),h.hasTouchCapability&&a(h.window.document.body).on("touchmove."+d.__namespace+"-triggerOpen",function(a){d._touchRecordEvent(a)}),d._on("created",function(){d.__prepareTooltip()})._on("repositioned",function(a){d.__lastPosition=a.position})}else d.__options.disabled=!0},__contentInsert:function(){var a=this,b=a._$tooltip.find(".tooltipster-content"),c=a.__Content,d=function(a){c=a};return a._trigger({type:"format",content:a.__Content,format:d}),a.__options.functionFormat&&(c=a.__options.functionFormat.call(a,a,{origin:a._$origin[0]},a.__Content)),"string"!=typeof c||a.__options.contentAsHTML?b.empty().append(c):b.text(c),a},__contentSet:function(b){return b instanceof a&&this.__options.contentCloning&&(b=b.clone(!0)),this.__Content=b,this._trigger({type:"updated",content:b}),this},__destroyError:function(){throw new Error("This tooltip has been destroyed and cannot execute your method call.")},__geometry:function(){var b=this,c=b._$origin,d=b._$origin.is("area");if(d){var e=b._$origin.parent().attr("name");c=a('img[usemap="#'+e+'"]')}var f=c[0].getBoundingClientRect(),g=a(h.window.document),i=a(h.window),j=c,k={available:{document:null,window:null},document:{size:{height:g.height(),width:g.width()}},window:{scroll:{left:h.window.scrollX||h.window.document.documentElement.scrollLeft,top:h.window.scrollY||h.window.document.documentElement.scrollTop},size:{height:i.height(),width:i.width()}},origin:{fixedLineage:!1,offset:{},size:{height:f.bottom-f.top,width:f.right-f.left},usemapImage:d?c[0]:null,windowOffset:{bottom:f.bottom,left:f.left,right:f.right,top:f.top}}};if(d){var l=b._$origin.attr("shape"),m=b._$origin.attr("coords");if(m&&(m=m.split(","),a.map(m,function(a,b){m[b]=parseInt(a)})),"default"!=l)switch(l){case"circle":var n=m[0],o=m[1],p=m[2],q=o-p,r=n-p;k.origin.size.height=2*p,k.origin.size.width=k.origin.size.height,k.origin.windowOffset.left+=r,k.origin.windowOffset.top+=q;break;case"rect":var s=m[0],t=m[1],u=m[2],v=m[3];k.origin.size.height=v-t,k.origin.size.width=u-s,k.origin.windowOffset.left+=s,k.origin.windowOffset.top+=t;break;case"poly":for(var w=0,x=0,y=0,z=0,A="even",B=0;By&&(y=C,0===B&&(w=y)),Cz&&(z=C,1==B&&(x=z)),C6e4}),d(b._$origin)||b.close(function(){b.destroy()})},2e4):clearInterval(b.__garbageCollector),b},__prepareOrigin:function(){var a=this;if(a._$origin.off("."+a.__namespace+"-triggerOpen"),h.hasTouchCapability&&a._$origin.on("touchstart."+a.__namespace+"-triggerOpen touchend."+a.__namespace+"-triggerOpen touchcancel."+a.__namespace+"-triggerOpen",function(b){a._touchRecordEvent(b)}),a.__options.triggerOpen.click||a.__options.triggerOpen.tap&&h.hasTouchCapability){var b="";a.__options.triggerOpen.click&&(b+="click."+a.__namespace+"-triggerOpen "),a.__options.triggerOpen.tap&&h.hasTouchCapability&&(b+="touchend."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){a._touchIsMeaningfulEvent(b)&&a._open(b)})}if(a.__options.triggerOpen.mouseenter||a.__options.triggerOpen.touchstart&&h.hasTouchCapability){var b="";a.__options.triggerOpen.mouseenter&&(b+="mouseenter."+a.__namespace+"-triggerOpen "),a.__options.triggerOpen.touchstart&&h.hasTouchCapability&&(b+="touchstart."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){!a._touchIsTouchEvent(b)&&a._touchIsEmulatedEvent(b)||(a.__pointerIsOverOrigin=!0,a._openShortly(b))})}if(a.__options.triggerClose.mouseleave||a.__options.triggerClose.touchleave&&h.hasTouchCapability){var b="";a.__options.triggerClose.mouseleave&&(b+="mouseleave."+a.__namespace+"-triggerOpen "),a.__options.triggerClose.touchleave&&h.hasTouchCapability&&(b+="touchend."+a.__namespace+"-triggerOpen touchcancel."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){a._touchIsMeaningfulEvent(b)&&(a.__pointerIsOverOrigin=!1)})}return a},__prepareTooltip:function(){var b=this,c=b.__options.interactive?"auto":"";return b._$tooltip.attr("id",b.__namespace).css({"pointer-events":c,zIndex:b.__options.zIndex}),a.each(b.__previousThemes,function(a,c){b._$tooltip.removeClass(c)}),a.each(b.__options.theme,function(a,c){b._$tooltip.addClass(c)}),b.__previousThemes=a.merge([],b.__options.theme),b},__scrollHandler:function(b){var c=this;if(c.__options.triggerClose.scroll)c._close(b);else if(d(c._$origin)&&d(c._$tooltip)){var e=null;if(b.target===h.window.document)c.__Geometry.origin.fixedLineage||c.__options.repositionOnScroll&&c.reposition(b);else{e=c.__geometry();var f=!1;if("fixed"!=c._$origin.css("position")&&c.__$originParents.each(function(b,d){var g=a(d),h=g.css("overflow-x"),i=g.css("overflow-y");if("visible"!=h||"visible"!=i){var j=d.getBoundingClientRect();if("visible"!=h&&(e.origin.windowOffset.leftj.right))return f=!0,!1;if("visible"!=i&&(e.origin.windowOffset.topj.bottom)&&(f=!c.__options.checkOverflowY||c.__options.checkOverflowY(e,j)))return!1}if("fixed"==g.css("position"))return!1}),f)c._$tooltip.css("visibility","hidden");else if(c._$tooltip.css("visibility","visible"),c.__options.repositionOnScroll)c.reposition(b);else{var g=e.origin.offset.left-c.__Geometry.origin.offset.left,i=e.origin.offset.top-c.__Geometry.origin.offset.top;c._$tooltip.css({left:c.__lastPosition.coord.left+g,top:c.__lastPosition.coord.top+i})}}c._trigger({type:"scroll",event:b,geo:e})}return c},__stateSet:function(a){return this.__state=a,this._trigger({type:"state",state:a}),this},__timeoutsClear:function(){return clearTimeout(this.__timeouts.open),this.__timeouts.open=null,a.each(this.__timeouts.close,function(a,b){clearTimeout(b)}),this.__timeouts.close=[],this},__trackerStart:function(){var a=this,b=a._$tooltip.find(".tooltipster-content");return a.__options.trackTooltip&&(a.__contentBcr=b[0].getBoundingClientRect()),a.__tracker=setInterval(function(){if(d(a._$origin)&&d(a._$tooltip)){if(a.__options.trackOrigin){var e=a.__geometry(),f=!1;c(e.origin.size,a.__Geometry.origin.size)&&(a.__Geometry.origin.fixedLineage?c(e.origin.windowOffset,a.__Geometry.origin.windowOffset)&&(f=!0):c(e.origin.offset,a.__Geometry.origin.offset)&&(f=!0)),f||(a.__options.triggerClose.mouseleave&&!a.__options.ignoreCloseOnScroll?a._close():a.reposition())}if(a.__options.trackTooltip){var g=b[0].getBoundingClientRect();g.height===a.__contentBcr.height&&g.width===a.__contentBcr.width||(a.reposition(),a.__contentBcr=g)}}else a._close()},a.__options.trackerInterval),a},_close:function(b,c,d){var e=this,f=!0;if(e._trigger({type:"close",event:b,stop:function(){f=!1}}),f||d){c&&e.__callbacks.close.push(c),e.__callbacks.open=[],e.__timeoutsClear();var g=function(){a.each(e.__callbacks.close,function(a,c){c.call(e,e,{event:b,origin:e._$origin[0]})}),e.__callbacks.close=[]};if("closed"!=e.__state){var i=!0,j=new Date,k=j.getTime(),l=k+e.__options.animationDuration[1];if("disappearing"==e.__state&&l>e.__closingTime&&e.__options.animationDuration[1]>0&&(i=!1),i){e.__closingTime=l,"disappearing"!=e.__state&&e.__stateSet("disappearing");var m=function(){clearInterval(e.__tracker),e._trigger({type:"closing",event:b}),e._$tooltip.off("."+e.__namespace+"-triggerClose").removeClass("tooltipster-dying"),a(h.window).off("."+e.__namespace+"-triggerClose"),e.__$originParents.each(function(b,c){a(c).off("scroll."+e.__namespace+"-triggerClose")}),e.__$originParents=null,a(h.window.document.body).off("."+e.__namespace+"-triggerClose"),e._$origin.off("."+e.__namespace+"-triggerClose"),e._off("dismissable"),e.__stateSet("closed"),e._trigger({type:"after",event:b}),e.__options.functionAfter&&e.__options.functionAfter.call(e,e,{event:b,origin:e._$origin[0]}),g()};h.hasTransitions?(e._$tooltip.css({"-moz-animation-duration":e.__options.animationDuration[1]+"ms","-ms-animation-duration":e.__options.animationDuration[1]+"ms","-o-animation-duration":e.__options.animationDuration[1]+"ms","-webkit-animation-duration":e.__options.animationDuration[1]+"ms","animation-duration":e.__options.animationDuration[1]+"ms","transition-duration":e.__options.animationDuration[1]+"ms"}),e._$tooltip.clearQueue().removeClass("tooltipster-show").addClass("tooltipster-dying"),e.__options.animationDuration[1]>0&&e._$tooltip.delay(e.__options.animationDuration[1]),e._$tooltip.queue(m)):e._$tooltip.stop().fadeOut(e.__options.animationDuration[1],m)}}else g()}return e},_off:function(){return this.__$emitterPrivate.off.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_on:function(){return this.__$emitterPrivate.on.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_one:function(){return this.__$emitterPrivate.one.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_open:function(b,c){var e=this;if(!e.__destroying&&d(e._$origin)&&e.__enabled){var f=!0;if("closed"==e.__state&&(e._trigger({type:"before",event:b,stop:function(){f=!1}}),f&&e.__options.functionBefore&&(f=e.__options.functionBefore.call(e,e,{event:b,origin:e._$origin[0]}))),f!==!1&&null!==e.__Content){c&&e.__callbacks.open.push(c),e.__callbacks.close=[],e.__timeoutsClear();var g,i=function(){"stable"!=e.__state&&e.__stateSet("stable"),a.each(e.__callbacks.open,function(a,b){b.call(e,e,{origin:e._$origin[0],tooltip:e._$tooltip[0]})}),e.__callbacks.open=[]};if("closed"!==e.__state)g=0,"disappearing"===e.__state?(e.__stateSet("appearing"),h.hasTransitions?(e._$tooltip.clearQueue().removeClass("tooltipster-dying").addClass("tooltipster-show"),e.__options.animationDuration[0]>0&&e._$tooltip.delay(e.__options.animationDuration[0]),e._$tooltip.queue(i)):e._$tooltip.stop().fadeIn(i)):"stable"==e.__state&&i();else{if(e.__stateSet("appearing"),g=e.__options.animationDuration[0],e.__contentInsert(),e.reposition(b,!0),h.hasTransitions?(e._$tooltip.addClass("tooltipster-"+e.__options.animation).addClass("tooltipster-initial").css({"-moz-animation-duration":e.__options.animationDuration[0]+"ms","-ms-animation-duration":e.__options.animationDuration[0]+"ms","-o-animation-duration":e.__options.animationDuration[0]+"ms","-webkit-animation-duration":e.__options.animationDuration[0]+"ms","animation-duration":e.__options.animationDuration[0]+"ms","transition-duration":e.__options.animationDuration[0]+"ms"}),setTimeout(function(){"closed"!=e.__state&&(e._$tooltip.addClass("tooltipster-show").removeClass("tooltipster-initial"),e.__options.animationDuration[0]>0&&e._$tooltip.delay(e.__options.animationDuration[0]),e._$tooltip.queue(i))},0)):e._$tooltip.css("display","none").fadeIn(e.__options.animationDuration[0],i),e.__trackerStart(),a(h.window).on("resize."+e.__namespace+"-triggerClose",function(b){var c=a(document.activeElement);(c.is("input")||c.is("textarea"))&&a.contains(e._$tooltip[0],c[0])||e.reposition(b)}).on("scroll."+e.__namespace+"-triggerClose",function(a){e.__scrollHandler(a)}),e.__$originParents=e._$origin.parents(),e.__$originParents.each(function(b,c){a(c).on("scroll."+e.__namespace+"-triggerClose",function(a){e.__scrollHandler(a)})}),e.__options.triggerClose.mouseleave||e.__options.triggerClose.touchleave&&h.hasTouchCapability){e._on("dismissable",function(a){a.dismissable?a.delay?(m=setTimeout(function(){e._close(a.event)},a.delay),e.__timeouts.close.push(m)):e._close(a):clearTimeout(m)});var j=e._$origin,k="",l="",m=null;e.__options.interactive&&(j=j.add(e._$tooltip)),e.__options.triggerClose.mouseleave&&(k+="mouseenter."+e.__namespace+"-triggerClose ",l+="mouseleave."+e.__namespace+"-triggerClose "),e.__options.triggerClose.touchleave&&h.hasTouchCapability&&(k+="touchstart."+e.__namespace+"-triggerClose",l+="touchend."+e.__namespace+"-triggerClose touchcancel."+e.__namespace+"-triggerClose"),j.on(l,function(a){if(e._touchIsTouchEvent(a)||!e._touchIsEmulatedEvent(a)){var b="mouseleave"==a.type?e.__options.delay:e.__options.delayTouch;e._trigger({delay:b[1],dismissable:!0,event:a,type:"dismissable"})}}).on(k,function(a){!e._touchIsTouchEvent(a)&&e._touchIsEmulatedEvent(a)||e._trigger({dismissable:!1,event:a,type:"dismissable"})})}e.__options.triggerClose.originClick&&e._$origin.on("click."+e.__namespace+"-triggerClose",function(a){e._touchIsTouchEvent(a)||e._touchIsEmulatedEvent(a)||e._close(a)}),(e.__options.triggerClose.click||e.__options.triggerClose.tap&&h.hasTouchCapability)&&setTimeout(function(){if("closed"!=e.__state){var b="",c=a(h.window.document.body);e.__options.triggerClose.click&&(b+="click."+e.__namespace+"-triggerClose "),e.__options.triggerClose.tap&&h.hasTouchCapability&&(b+="touchend."+e.__namespace+"-triggerClose"),c.on(b,function(b){e._touchIsMeaningfulEvent(b)&&(e._touchRecordEvent(b),e.__options.interactive&&a.contains(e._$tooltip[0],b.target)||e._close(b))}),e.__options.triggerClose.tap&&h.hasTouchCapability&&c.on("touchstart."+e.__namespace+"-triggerClose",function(a){e._touchRecordEvent(a)})}},0),e._trigger("ready"),e.__options.functionReady&&e.__options.functionReady.call(e,e,{origin:e._$origin[0],tooltip:e._$tooltip[0]})}if(e.__options.timer>0){var m=setTimeout(function(){e._close()},e.__options.timer+g);e.__timeouts.close.push(m)}}}return e},_openShortly:function(a){var b=this,c=!0;if("stable"!=b.__state&&"appearing"!=b.__state&&!b.__timeouts.open&&(b._trigger({type:"start",event:a,stop:function(){c=!1}}),c)){var d=0==a.type.indexOf("touch")?b.__options.delayTouch:b.__options.delay;d[0]?b.__timeouts.open=setTimeout(function(){b.__timeouts.open=null,b.__pointerIsOverOrigin&&b._touchIsMeaningfulEvent(a)?(b._trigger("startend"),b._open(a)):b._trigger("startcancel")},d[0]):(b._trigger("startend"),b._open(a))}return b},_optionsExtract:function(b,c){var d=this,e=a.extend(!0,{},c),f=d.__options[b];return f||(f={},a.each(c,function(a,b){var c=d.__options[a];void 0!==c&&(f[a]=c)})),a.each(e,function(b,c){void 0!==f[b]&&("object"!=typeof c||c instanceof Array||null==c||"object"!=typeof f[b]||f[b]instanceof Array||null==f[b]?e[b]=f[b]:a.extend(e[b],f[b]))}),e},_plug:function(b){var c=a.tooltipster._plugin(b);if(!c)throw new Error('The "'+b+'" plugin is not defined');return c.instance&&a.tooltipster.__bridge(c.instance,this,c.name),this},_touchIsEmulatedEvent:function(a){for(var b=!1,c=(new Date).getTime(),d=this.__touchEvents.length-1;d>=0;d--){var e=this.__touchEvents[d];if(!(c-e.time<500))break;e.target===a.target&&(b=!0)}return b},_touchIsMeaningfulEvent:function(a){return this._touchIsTouchEvent(a)&&!this._touchSwiped(a.target)||!this._touchIsTouchEvent(a)&&!this._touchIsEmulatedEvent(a)},_touchIsTouchEvent:function(a){return 0==a.type.indexOf("touch")},_touchRecordEvent:function(a){return this._touchIsTouchEvent(a)&&(a.time=(new Date).getTime(),this.__touchEvents.push(a)),this},_touchSwiped:function(a){for(var b=!1,c=this.__touchEvents.length-1;c>=0;c--){var d=this.__touchEvents[c];if("touchmove"==d.type){b=!0;break}if("touchstart"==d.type&&a===d.target)break}return b},_trigger:function(){var b=Array.prototype.slice.apply(arguments);return"string"==typeof b[0]&&(b[0]={type:b[0]}),b[0].instance=this,b[0].origin=this._$origin?this._$origin[0]:null,b[0].tooltip=this._$tooltip?this._$tooltip[0]:null,this.__$emitterPrivate.trigger.apply(this.__$emitterPrivate,b),a.tooltipster._trigger.apply(a.tooltipster,b),this.__$emitterPublic.trigger.apply(this.__$emitterPublic,b),this},_unplug:function(b){var c=this;if(c[b]){var d=a.tooltipster._plugin(b);d.instance&&a.each(d.instance,function(a,d){c[a]&&c[a].bridged===c[b]&&delete c[a]}),c[b].__destroy&&c[b].__destroy(),delete c[b]}return c},close:function(a){return this.__destroyed?this.__destroyError():this._close(null,a),this},content:function(a){var b=this;if(void 0===a)return b.__Content;if(b.__destroyed)b.__destroyError();else if(b.__contentSet(a),null!==b.__Content){if("closed"!==b.__state&&(b.__contentInsert(),b.reposition(),b.__options.updateAnimation))if(h.hasTransitions){var c=b.__options.updateAnimation;b._$tooltip.addClass("tooltipster-update-"+c),setTimeout(function(){"closed"!=b.__state&&b._$tooltip.removeClass("tooltipster-update-"+c)},1e3)}else b._$tooltip.fadeTo(200,.5,function(){"closed"!=b.__state&&b._$tooltip.fadeTo(200,1)})}else b._close();return b},destroy:function(){var b=this;if(b.__destroyed)b.__destroyError();else{"closed"!=b.__state?b.option("animationDuration",0)._close(null,null,!0):b.__timeoutsClear(),b._trigger("destroy"),b.__destroyed=!0,b._$origin.removeData(b.__namespace).off("."+b.__namespace+"-triggerOpen"),a(h.window.document.body).off("."+b.__namespace+"-triggerOpen");var c=b._$origin.data("tooltipster-ns");if(c)if(1===c.length){var d=null;"previous"==b.__options.restoration?d=b._$origin.data("tooltipster-initialTitle"):"current"==b.__options.restoration&&(d="string"==typeof b.__Content?b.__Content:a("
    ").append(b.__Content).html()),d&&b._$origin.attr("title",d),b._$origin.removeClass("tooltipstered"),b._$origin.removeData("tooltipster-ns").removeData("tooltipster-initialTitle")}else c=a.grep(c,function(a,c){return a!==b.__namespace}),b._$origin.data("tooltipster-ns",c);b._trigger("destroyed"),b._off(),b.off(),b.__Content=null,b.__$emitterPrivate=null,b.__$emitterPublic=null,b.__options.parent=null,b._$origin=null,b._$tooltip=null,a.tooltipster.__instancesLatestArr=a.grep(a.tooltipster.__instancesLatestArr,function(a,c){return b!==a}),clearInterval(b.__garbageCollector)}return b},disable:function(){return this.__destroyed?(this.__destroyError(),this):(this._close(),this.__enabled=!1,this)},elementOrigin:function(){return this.__destroyed?void this.__destroyError():this._$origin[0]},elementTooltip:function(){return this._$tooltip?this._$tooltip[0]:null},enable:function(){return this.__enabled=!0,this},hide:function(a){return this.close(a)},instance:function(){return this},off:function(){return this.__destroyed||this.__$emitterPublic.off.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},on:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.on.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},one:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.one.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},open:function(a){return this.__destroyed?this.__destroyError():this._open(null,a),this},option:function(b,c){return void 0===c?this.__options[b]:(this.__destroyed?this.__destroyError():(this.__options[b]=c,this.__optionsFormat(),a.inArray(b,["trigger","triggerClose","triggerOpen"])>=0&&this.__prepareOrigin(),"selfDestruction"===b&&this.__prepareGC()),this)},reposition:function(a,b){var c=this;return c.__destroyed?c.__destroyError():"closed"!=c.__state&&d(c._$origin)&&(b||d(c._$tooltip))&&(b||c._$tooltip.detach(),c.__Geometry=c.__geometry(),c._trigger({type:"reposition",event:a,helper:{geo:c.__Geometry}})),c},show:function(a){return this.open(a)},status:function(){return{destroyed:this.__destroyed,enabled:this.__enabled,open:"closed"!==this.__state,state:this.__state}},triggerHandler:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.triggerHandler.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this}},a.fn.tooltipster=function(){var b=Array.prototype.slice.apply(arguments),c="You are using a single HTML element as content for several tooltips. You probably want to set the contentCloning option to TRUE.";if(0===this.length)return this;if("string"==typeof b[0]){var d="#*$~&";return this.each(function(){var e=a(this).data("tooltipster-ns"),f=e?a(this).data(e[0]):null;if(!f)throw new Error("You called Tooltipster's \""+b[0]+'" method on an uninitialized element');if("function"!=typeof f[b[0]])throw new Error('Unknown method "'+b[0]+'"');this.length>1&&"content"==b[0]&&(b[1]instanceof a||"object"==typeof b[1]&&null!=b[1]&&b[1].tagName)&&!f.__options.contentCloning&&f.__options.debug&&console.log(c);var g=f[b[0]](b[1],b[2]);if(g!==f||"instance"===b[0])return d=g,!1}),"#*$~&"!==d?d:this}a.tooltipster.__instancesLatestArr=[];var e=b[0]&&void 0!==b[0].multiple,g=e&&b[0].multiple||!e&&f.multiple,h=b[0]&&void 0!==b[0].content,i=h&&b[0].content||!h&&f.content,j=b[0]&&void 0!==b[0].contentCloning,k=j&&b[0].contentCloning||!j&&f.contentCloning,l=b[0]&&void 0!==b[0].debug,m=l&&b[0].debug||!l&&f.debug;return this.length>1&&(i instanceof a||"object"==typeof i&&null!=i&&i.tagName)&&!k&&m&&console.log(c),this.each(function(){var c=!1,d=a(this),e=d.data("tooltipster-ns"),f=null;e?g?c=!0:m&&(console.log("Tooltipster: one or more tooltips are already attached to the element below. Ignoring."),console.log(this)):c=!0,c&&(f=new a.Tooltipster(this,b[0]),e||(e=[]),e.push(f.__namespace),d.data("tooltipster-ns",e),d.data(f.__namespace,f),f.__options.functionInit&&f.__options.functionInit.call(f,f,{origin:this}),f._trigger("init")),a.tooltipster.__instancesLatestArr.push(f)}),this},b.prototype={__init:function(b){this.__$tooltip=b,this.__$tooltip.css({left:0,overflow:"hidden",position:"absolute",top:0}).find(".tooltipster-content").css("overflow","auto"),this.$container=a('
    ').append(this.__$tooltip).appendTo(h.window.document.body)},__forceRedraw:function(){var a=this.__$tooltip.parent();this.__$tooltip.detach(),this.__$tooltip.appendTo(a)},constrain:function(a,b){return this.constraints={width:a,height:b},this.__$tooltip.css({display:"block",height:"",overflow:"auto",width:a}),this},destroy:function(){this.__$tooltip.detach().find(".tooltipster-content").css({display:"",overflow:""}),this.$container.remove()},free:function(){return this.constraints=null,this.__$tooltip.css({display:"",height:"",overflow:"visible",width:""}),this},measure:function(){this.__forceRedraw();var a=this.__$tooltip[0].getBoundingClientRect(),b={size:{height:a.height||a.bottom-a.top,width:a.width||a.right-a.left}};if(this.constraints){var c=this.__$tooltip.find(".tooltipster-content"),d=this.__$tooltip.outerHeight(),e=c[0].getBoundingClientRect(),f={height:d<=this.constraints.height,width:a.width<=this.constraints.width&&e.width>=c[0].scrollWidth-1};b.fits=f.height&&f.width}return h.IE&&h.IE<=11&&b.size.width!==h.window.document.documentElement.clientWidth&&(b.size.width=Math.ceil(b.size.width)+1),b}};var j=navigator.userAgent.toLowerCase();j.indexOf("msie")!=-1?h.IE=parseInt(j.split("msie")[1]):j.toLowerCase().indexOf("trident")!==-1&&j.indexOf(" rv:11")!==-1?h.IE=11:j.toLowerCase().indexOf("edge/")!=-1&&(h.IE=parseInt(j.toLowerCase().split("edge/")[1]));var k="tooltipster.sideTip";return a.tooltipster._plugin({name:k,instance:{__defaults:function(){return{arrow:!0,distance:6,functionPosition:null,maxWidth:null,minIntersection:16,minWidth:0,position:null,side:"top",viewportAware:!0}},__init:function(a){var b=this;b.__instance=a,b.__namespace="tooltipster-sideTip-"+Math.round(1e6*Math.random()),b.__previousState="closed",b.__options,b.__optionsFormat(),b.__instance._on("state."+b.__namespace,function(a){"closed"==a.state?b.__close():"appearing"==a.state&&"closed"==b.__previousState&&b.__create(),b.__previousState=a.state}),b.__instance._on("options."+b.__namespace,function(){b.__optionsFormat()}),b.__instance._on("reposition."+b.__namespace,function(a){b.__reposition(a.event,a.helper)})},__close:function(){this.__instance.content()instanceof a&&this.__instance.content().detach(),this.__instance._$tooltip.remove(),this.__instance._$tooltip=null},__create:function(){var b=a('
    ');this.__options.arrow||b.find(".tooltipster-box").css("margin",0).end().find(".tooltipster-arrow").hide(), ++this.__options.minWidth&&b.css("min-width",this.__options.minWidth+"px"),this.__options.maxWidth&&b.css("max-width",this.__options.maxWidth+"px"),this.__instance._$tooltip=b,this.__instance._trigger("created")},__destroy:function(){this.__instance._off("."+self.__namespace)},__optionsFormat:function(){var b=this;if(b.__options=b.__instance._optionsExtract(k,b.__defaults()),b.__options.position&&(b.__options.side=b.__options.position),"object"!=typeof b.__options.distance&&(b.__options.distance=[b.__options.distance]),b.__options.distance.length<4&&(void 0===b.__options.distance[1]&&(b.__options.distance[1]=b.__options.distance[0]),void 0===b.__options.distance[2]&&(b.__options.distance[2]=b.__options.distance[0]),void 0===b.__options.distance[3]&&(b.__options.distance[3]=b.__options.distance[1])),b.__options.distance={top:b.__options.distance[0],right:b.__options.distance[1],bottom:b.__options.distance[2],left:b.__options.distance[3]},"string"==typeof b.__options.side){var c={top:"bottom",right:"left",bottom:"top",left:"right"};b.__options.side=[b.__options.side,c[b.__options.side]],"left"==b.__options.side[0]||"right"==b.__options.side[0]?b.__options.side.push("top","bottom"):b.__options.side.push("right","left")}6===a.tooltipster._env.IE&&b.__options.arrow!==!0&&(b.__options.arrow=!1)},__reposition:function(b,c){var d,e=this,f=e.__targetFind(c),g=[];e.__instance._$tooltip.detach();var h=e.__instance._$tooltip.clone(),i=a.tooltipster._getRuler(h),j=!1,k=e.__instance.option("animation");switch(k&&h.removeClass("tooltipster-"+k),a.each(["window","document"],function(d,k){var l=null;if(e.__instance._trigger({container:k,helper:c,satisfied:j,takeTest:function(a){l=a},results:g,type:"positionTest"}),1==l||0!=l&&0==j&&("window"!=k||e.__options.viewportAware))for(var d=0;d=h.outerSize.width&&c.geo.available[k][n].height>=h.outerSize.height?h.fits=!0:h.fits=!1:h.fits=p.fits,"window"==k&&(h.fits?"top"==n||"bottom"==n?h.whole=c.geo.origin.windowOffset.right>=e.__options.minIntersection&&c.geo.window.size.width-c.geo.origin.windowOffset.left>=e.__options.minIntersection:h.whole=c.geo.origin.windowOffset.bottom>=e.__options.minIntersection&&c.geo.window.size.height-c.geo.origin.windowOffset.top>=e.__options.minIntersection:h.whole=!1),g.push(h),h.whole)j=!0;else if("natural"==h.mode&&(h.fits||h.size.width<=c.geo.available[k][n].width))return!1}})}}),e.__instance._trigger({edit:function(a){g=a},event:b,helper:c,results:g,type:"positionTested"}),g.sort(function(a,b){if(a.whole&&!b.whole)return-1;if(!a.whole&&b.whole)return 1;if(a.whole&&b.whole){var c=e.__options.side.indexOf(a.side),d=e.__options.side.indexOf(b.side);return cd?1:"natural"==a.mode?-1:1}if(a.fits&&!b.fits)return-1;if(!a.fits&&b.fits)return 1;if(a.fits&&b.fits){var c=e.__options.side.indexOf(a.side),d=e.__options.side.indexOf(b.side);return cd?1:"natural"==a.mode?-1:1}return"document"==a.container&&"bottom"==a.side&&"natural"==a.mode?-1:1}),d=g[0],d.coord={},d.side){case"left":case"right":d.coord.top=Math.floor(d.target-d.size.height/2);break;case"bottom":case"top":d.coord.left=Math.floor(d.target-d.size.width/2)}switch(d.side){case"left":d.coord.left=c.geo.origin.windowOffset.left-d.outerSize.width;break;case"right":d.coord.left=c.geo.origin.windowOffset.right+d.distance.horizontal;break;case"top":d.coord.top=c.geo.origin.windowOffset.top-d.outerSize.height;break;case"bottom":d.coord.top=c.geo.origin.windowOffset.bottom+d.distance.vertical}"window"==d.container?"top"==d.side||"bottom"==d.side?d.coord.left<0?c.geo.origin.windowOffset.right-this.__options.minIntersection>=0?d.coord.left=0:d.coord.left=c.geo.origin.windowOffset.right-this.__options.minIntersection-1:d.coord.left>c.geo.window.size.width-d.size.width&&(c.geo.origin.windowOffset.left+this.__options.minIntersection<=c.geo.window.size.width?d.coord.left=c.geo.window.size.width-d.size.width:d.coord.left=c.geo.origin.windowOffset.left+this.__options.minIntersection+1-d.size.width):d.coord.top<0?c.geo.origin.windowOffset.bottom-this.__options.minIntersection>=0?d.coord.top=0:d.coord.top=c.geo.origin.windowOffset.bottom-this.__options.minIntersection-1:d.coord.top>c.geo.window.size.height-d.size.height&&(c.geo.origin.windowOffset.top+this.__options.minIntersection<=c.geo.window.size.height?d.coord.top=c.geo.window.size.height-d.size.height:d.coord.top=c.geo.origin.windowOffset.top+this.__options.minIntersection+1-d.size.height):(d.coord.left>c.geo.window.size.width-d.size.width&&(d.coord.left=c.geo.window.size.width-d.size.width),d.coord.left<0&&(d.coord.left=0)),e.__sideChange(h,d.side),c.tooltipClone=h[0],c.tooltipParent=e.__instance.option("parent").parent[0],c.mode=d.mode,c.whole=d.whole,c.origin=e.__instance._$origin[0],c.tooltip=e.__instance._$tooltip[0],delete d.container,delete d.fits,delete d.mode,delete d.outerSize,delete d.whole,d.distance=d.distance.horizontal||d.distance.vertical;var l=a.extend(!0,{},d);if(e.__instance._trigger({edit:function(a){d=a},event:b,helper:c,position:l,type:"position"}),e.__options.functionPosition){var m=e.__options.functionPosition.call(e,e.__instance,c,l);m&&(d=m)}i.destroy();var n,o;"top"==d.side||"bottom"==d.side?(n={prop:"left",val:d.target-d.coord.left},o=d.size.width-this.__options.minIntersection):(n={prop:"top",val:d.target-d.coord.top},o=d.size.height-this.__options.minIntersection),n.valo&&(n.val=o);var p;p=c.geo.origin.fixedLineage?c.geo.origin.windowOffset:{left:c.geo.origin.windowOffset.left+c.geo.window.scroll.left,top:c.geo.origin.windowOffset.top+c.geo.window.scroll.top},d.coord={left:p.left+(d.coord.left-c.geo.origin.windowOffset.left),top:p.top+(d.coord.top-c.geo.origin.windowOffset.top)},e.__sideChange(e.__instance._$tooltip,d.side),c.geo.origin.fixedLineage?e.__instance._$tooltip.css("position","fixed"):e.__instance._$tooltip.css("position",""),e.__instance._$tooltip.css({left:d.coord.left,top:d.coord.top,height:d.size.height,width:d.size.width}).find(".tooltipster-arrow").css({left:"",top:""}).css(n.prop,n.val),e.__instance._$tooltip.appendTo(e.__instance.option("parent")),e.__instance._trigger({type:"repositioned",event:b,position:d})},__sideChange:function(a,b){a.removeClass("tooltipster-bottom").removeClass("tooltipster-left").removeClass("tooltipster-right").removeClass("tooltipster-top").addClass("tooltipster-"+b)},__targetFind:function(a){var b={},c=this.__instance._$origin[0].getClientRects();if(c.length>1){var d=this.__instance._$origin.css("opacity");1==d&&(this.__instance._$origin.css("opacity",.99),c=this.__instance._$origin[0].getClientRects(),this.__instance._$origin.css("opacity",1))}if(c.length<2)b.top=Math.floor(a.geo.origin.windowOffset.left+a.geo.origin.size.width/2),b.bottom=b.top,b.left=Math.floor(a.geo.origin.windowOffset.top+a.geo.origin.size.height/2),b.right=b.left;else{var e=c[0];b.top=Math.floor(e.left+(e.right-e.left)/2),e=c.length>2?c[Math.ceil(c.length/2)-1]:c[0],b.right=Math.floor(e.top+(e.bottom-e.top)/2),e=c[c.length-1],b.bottom=Math.floor(e.left+(e.right-e.left)/2),e=c.length>2?c[Math.ceil((c.length+1)/2)-1]:c[c.length-1],b.left=Math.floor(e.top+(e.bottom-e.top)/2)}return b}}}),a}); +\ No newline at end of file +diff --git a/node_modules/tooltipster/dist/js/tooltipster.main.js b/node_modules/tooltipster/dist/js/tooltipster.main.js +index 34c11d8..44efcd5 100644 +--- a/node_modules/tooltipster/dist/js/tooltipster.main.js ++++ b/node_modules/tooltipster/dist/js/tooltipster.main.js +@@ -5,18 +5,19 @@ + * MIT license + */ + (function (root, factory) { ++ if (root === undefined && window !== undefined) root = window; + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module unless amdModuleId is set + define(["jquery"], function (a0) { + return (factory(a0)); + }); +- } else if (typeof exports === 'object') { ++ } else if (typeof module === 'object' && module.exports) { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(require("jquery")); + } else { +- factory(jQuery); ++ factory(root["jQuery"]); + } + }(this, function ($) { + +@@ -1260,8 +1261,14 @@ $.Tooltipster.prototype = { + if ( geo.origin.windowOffset.top < bcr.top + || geo.origin.windowOffset.bottom > bcr.bottom + ) { +- overflows = true; +- return false; ++ if (self.__options.checkOverflowY) { ++ overflows = self.__options.checkOverflowY(geo, bcr); ++ } else { ++ overflows = true; ++ } ++ if (overflows) { ++ return false; ++ } + } + } + } +@@ -1412,7 +1419,7 @@ $.Tooltipster.prototype = { + + // close the tooltip when using the mouseleave close trigger + // (see https://github.com/iamceege/tooltipster/pull/253) +- if (self.__options.triggerClose.mouseleave) { ++ if (self.__options.triggerClose.mouseleave && !self.__options.ignoreCloseOnScroll) { + self._close(); + } + else { +@@ -3340,6 +3347,7 @@ function transitionSupport() { + + // we'll return jQuery for plugins not to have to declare it as a dependency, + // but it's done by a build task since it should be included only once at the +-// end when we concatenate the main file with a pluginreturn $; ++// end when we concatenate the main file with a plugin ++return $; + + })); +diff --git a/node_modules/tooltipster/dist/js/tooltipster.main.min.js b/node_modules/tooltipster/dist/js/tooltipster.main.min.js +index 1221ae9..b2b3ac9 100644 +--- a/node_modules/tooltipster/dist/js/tooltipster.main.min.js ++++ b/node_modules/tooltipster/dist/js/tooltipster.main.min.js +@@ -1 +1 @@ +-/*! tooltipster v4.2.8 */!function(a,b){"function"==typeof define&&define.amd?define(["jquery"],function(a){return b(a)}):"object"==typeof exports?module.exports=b(require("jquery")):b(jQuery)}(this,function(a){function b(a){this.$container,this.constraints=null,this.__$tooltip,this.__init(a)}function c(b,c){var d=!0;return a.each(b,function(a,e){return void 0===c[a]||b[a]!==c[a]?(d=!1,!1):void 0}),d}function d(b){var c=b.attr("id"),d=c?h.window.document.getElementById(c):null;return d?d===b[0]:a.contains(h.window.document.body,b[0])}function e(){if(!g)return!1;var a=g.document.body||g.document.documentElement,b=a.style,c="transition",d=["Moz","Webkit","Khtml","O","ms"];if("string"==typeof b[c])return!0;c=c.charAt(0).toUpperCase()+c.substr(1);for(var e=0;e0?e=c.__plugins[d]:a.each(c.__plugins,function(a,b){return b.name.substring(b.name.length-d.length-1)=="."+d?(e=b,!1):void 0}),e}if(b.name.indexOf(".")<0)throw new Error("Plugins must be namespaced");return c.__plugins[b.name]=b,b.core&&c.__bridge(b.core,c,b.name),this},_trigger:function(){var a=Array.prototype.slice.apply(arguments);return"string"==typeof a[0]&&(a[0]={type:a[0]}),this.__$emitterPrivate.trigger.apply(this.__$emitterPrivate,a),this.__$emitterPublic.trigger.apply(this.__$emitterPublic,a),this},instances:function(b){var c=[],d=b||".tooltipstered";return a(d).each(function(){var b=a(this),d=b.data("tooltipster-ns");d&&a.each(d,function(a,d){c.push(b.data(d))})}),c},instancesLatest:function(){return this.__instancesLatestArr},off:function(){return this.__$emitterPublic.off.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},on:function(){return this.__$emitterPublic.on.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},one:function(){return this.__$emitterPublic.one.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},origins:function(b){var c=b?b+" ":"";return a(c+".tooltipstered").toArray()},setDefaults:function(b){return a.extend(f,b),this},triggerHandler:function(){return this.__$emitterPublic.triggerHandler.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this}},a.tooltipster=new i,a.Tooltipster=function(b,c){this.__callbacks={close:[],open:[]},this.__closingTime,this.__Content,this.__contentBcr,this.__destroyed=!1,this.__$emitterPrivate=a({}),this.__$emitterPublic=a({}),this.__enabled=!0,this.__garbageCollector,this.__Geometry,this.__lastPosition,this.__namespace="tooltipster-"+Math.round(1e6*Math.random()),this.__options,this.__$originParents,this.__pointerIsOverOrigin=!1,this.__previousThemes=[],this.__state="closed",this.__timeouts={close:[],open:null},this.__touchEvents=[],this.__tracker=null,this._$origin,this._$tooltip,this.__init(b,c)},a.Tooltipster.prototype={__init:function(b,c){var d=this;if(d._$origin=a(b),d.__options=a.extend(!0,{},f,c),d.__optionsFormat(),!h.IE||h.IE>=d.__options.IEmin){var e=null;if(void 0===d._$origin.data("tooltipster-initialTitle")&&(e=d._$origin.attr("title"),void 0===e&&(e=null),d._$origin.data("tooltipster-initialTitle",e)),null!==d.__options.content)d.__contentSet(d.__options.content);else{var g,i=d._$origin.attr("data-tooltip-content");i&&(g=a(i)),g&&g[0]?d.__contentSet(g.first()):d.__contentSet(e)}d._$origin.removeAttr("title").addClass("tooltipstered"),d.__prepareOrigin(),d.__prepareGC(),a.each(d.__options.plugins,function(a,b){d._plug(b)}),h.hasTouchCapability&&a(h.window.document.body).on("touchmove."+d.__namespace+"-triggerOpen",function(a){d._touchRecordEvent(a)}),d._on("created",function(){d.__prepareTooltip()})._on("repositioned",function(a){d.__lastPosition=a.position})}else d.__options.disabled=!0},__contentInsert:function(){var a=this,b=a._$tooltip.find(".tooltipster-content"),c=a.__Content,d=function(a){c=a};return a._trigger({type:"format",content:a.__Content,format:d}),a.__options.functionFormat&&(c=a.__options.functionFormat.call(a,a,{origin:a._$origin[0]},a.__Content)),"string"!=typeof c||a.__options.contentAsHTML?b.empty().append(c):b.text(c),a},__contentSet:function(b){return b instanceof a&&this.__options.contentCloning&&(b=b.clone(!0)),this.__Content=b,this._trigger({type:"updated",content:b}),this},__destroyError:function(){throw new Error("This tooltip has been destroyed and cannot execute your method call.")},__geometry:function(){var b=this,c=b._$origin,d=b._$origin.is("area");if(d){var e=b._$origin.parent().attr("name");c=a('img[usemap="#'+e+'"]')}var f=c[0].getBoundingClientRect(),g=a(h.window.document),i=a(h.window),j=c,k={available:{document:null,window:null},document:{size:{height:g.height(),width:g.width()}},window:{scroll:{left:h.window.scrollX||h.window.document.documentElement.scrollLeft,top:h.window.scrollY||h.window.document.documentElement.scrollTop},size:{height:i.height(),width:i.width()}},origin:{fixedLineage:!1,offset:{},size:{height:f.bottom-f.top,width:f.right-f.left},usemapImage:d?c[0]:null,windowOffset:{bottom:f.bottom,left:f.left,right:f.right,top:f.top}}};if(d){var l=b._$origin.attr("shape"),m=b._$origin.attr("coords");if(m&&(m=m.split(","),a.map(m,function(a,b){m[b]=parseInt(a)})),"default"!=l)switch(l){case"circle":var n=m[0],o=m[1],p=m[2],q=o-p,r=n-p;k.origin.size.height=2*p,k.origin.size.width=k.origin.size.height,k.origin.windowOffset.left+=r,k.origin.windowOffset.top+=q;break;case"rect":var s=m[0],t=m[1],u=m[2],v=m[3];k.origin.size.height=v-t,k.origin.size.width=u-s,k.origin.windowOffset.left+=s,k.origin.windowOffset.top+=t;break;case"poly":for(var w=0,x=0,y=0,z=0,A="even",B=0;By&&(y=C,0===B&&(w=y)),w>C&&(w=C),A="odd"):(C>z&&(z=C,1==B&&(x=z)),x>C&&(x=C),A="even")}k.origin.size.height=z-x,k.origin.size.width=y-w,k.origin.windowOffset.left+=w,k.origin.windowOffset.top+=x}}var D=function(a){k.origin.size.height=a.height,k.origin.windowOffset.left=a.left,k.origin.windowOffset.top=a.top,k.origin.size.width=a.width};for(b._trigger({type:"geometry",edit:D,geometry:{height:k.origin.size.height,left:k.origin.windowOffset.left,top:k.origin.windowOffset.top,width:k.origin.size.width}}),k.origin.windowOffset.right=k.origin.windowOffset.left+k.origin.size.width,k.origin.windowOffset.bottom=k.origin.windowOffset.top+k.origin.size.height,k.origin.offset.left=k.origin.windowOffset.left+k.window.scroll.left,k.origin.offset.top=k.origin.windowOffset.top+k.window.scroll.top,k.origin.offset.bottom=k.origin.offset.top+k.origin.size.height,k.origin.offset.right=k.origin.offset.left+k.origin.size.width,k.available.document={bottom:{height:k.document.size.height-k.origin.offset.bottom,width:k.document.size.width},left:{height:k.document.size.height,width:k.origin.offset.left},right:{height:k.document.size.height,width:k.document.size.width-k.origin.offset.right},top:{height:k.origin.offset.top,width:k.document.size.width}},k.available.window={bottom:{height:Math.max(k.window.size.height-Math.max(k.origin.windowOffset.bottom,0),0),width:k.window.size.width},left:{height:k.window.size.height,width:Math.max(k.origin.windowOffset.left,0)},right:{height:k.window.size.height,width:Math.max(k.window.size.width-Math.max(k.origin.windowOffset.right,0),0)},top:{height:Math.max(k.origin.windowOffset.top,0),width:k.window.size.width}};"html"!=j[0].tagName.toLowerCase();){if("fixed"==j.css("position")){k.origin.fixedLineage=!0;break}j=j.parent()}return k},__optionsFormat:function(){return"number"==typeof this.__options.animationDuration&&(this.__options.animationDuration=[this.__options.animationDuration,this.__options.animationDuration]),"number"==typeof this.__options.delay&&(this.__options.delay=[this.__options.delay,this.__options.delay]),"number"==typeof this.__options.delayTouch&&(this.__options.delayTouch=[this.__options.delayTouch,this.__options.delayTouch]),"string"==typeof this.__options.theme&&(this.__options.theme=[this.__options.theme]),null===this.__options.parent?this.__options.parent=a(h.window.document.body):"string"==typeof this.__options.parent&&(this.__options.parent=a(this.__options.parent)),"hover"==this.__options.trigger?(this.__options.triggerOpen={mouseenter:!0,touchstart:!0},this.__options.triggerClose={mouseleave:!0,originClick:!0,touchleave:!0}):"click"==this.__options.trigger&&(this.__options.triggerOpen={click:!0,tap:!0},this.__options.triggerClose={click:!0,tap:!0}),this._trigger("options"),this},__prepareGC:function(){var b=this;return b.__options.selfDestruction?b.__garbageCollector=setInterval(function(){var c=(new Date).getTime();b.__touchEvents=a.grep(b.__touchEvents,function(a,b){return c-a.time>6e4}),d(b._$origin)||b.close(function(){b.destroy()})},2e4):clearInterval(b.__garbageCollector),b},__prepareOrigin:function(){var a=this;if(a._$origin.off("."+a.__namespace+"-triggerOpen"),h.hasTouchCapability&&a._$origin.on("touchstart."+a.__namespace+"-triggerOpen touchend."+a.__namespace+"-triggerOpen touchcancel."+a.__namespace+"-triggerOpen",function(b){a._touchRecordEvent(b)}),a.__options.triggerOpen.click||a.__options.triggerOpen.tap&&h.hasTouchCapability){var b="";a.__options.triggerOpen.click&&(b+="click."+a.__namespace+"-triggerOpen "),a.__options.triggerOpen.tap&&h.hasTouchCapability&&(b+="touchend."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){a._touchIsMeaningfulEvent(b)&&a._open(b)})}if(a.__options.triggerOpen.mouseenter||a.__options.triggerOpen.touchstart&&h.hasTouchCapability){var b="";a.__options.triggerOpen.mouseenter&&(b+="mouseenter."+a.__namespace+"-triggerOpen "),a.__options.triggerOpen.touchstart&&h.hasTouchCapability&&(b+="touchstart."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){!a._touchIsTouchEvent(b)&&a._touchIsEmulatedEvent(b)||(a.__pointerIsOverOrigin=!0,a._openShortly(b))})}if(a.__options.triggerClose.mouseleave||a.__options.triggerClose.touchleave&&h.hasTouchCapability){var b="";a.__options.triggerClose.mouseleave&&(b+="mouseleave."+a.__namespace+"-triggerOpen "),a.__options.triggerClose.touchleave&&h.hasTouchCapability&&(b+="touchend."+a.__namespace+"-triggerOpen touchcancel."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){a._touchIsMeaningfulEvent(b)&&(a.__pointerIsOverOrigin=!1)})}return a},__prepareTooltip:function(){var b=this,c=b.__options.interactive?"auto":"";return b._$tooltip.attr("id",b.__namespace).css({"pointer-events":c,zIndex:b.__options.zIndex}),a.each(b.__previousThemes,function(a,c){b._$tooltip.removeClass(c)}),a.each(b.__options.theme,function(a,c){b._$tooltip.addClass(c)}),b.__previousThemes=a.merge([],b.__options.theme),b},__scrollHandler:function(b){var c=this;if(c.__options.triggerClose.scroll)c._close(b);else if(d(c._$origin)&&d(c._$tooltip)){var e=null;if(b.target===h.window.document)c.__Geometry.origin.fixedLineage||c.__options.repositionOnScroll&&c.reposition(b);else{e=c.__geometry();var f=!1;if("fixed"!=c._$origin.css("position")&&c.__$originParents.each(function(b,c){var d=a(c),g=d.css("overflow-x"),h=d.css("overflow-y");if("visible"!=g||"visible"!=h){var i=c.getBoundingClientRect();if("visible"!=g&&(e.origin.windowOffset.lefti.right))return f=!0,!1;if("visible"!=h&&(e.origin.windowOffset.topi.bottom))return f=!0,!1}return"fixed"==d.css("position")?!1:void 0}),f)c._$tooltip.css("visibility","hidden");else if(c._$tooltip.css("visibility","visible"),c.__options.repositionOnScroll)c.reposition(b);else{var g=e.origin.offset.left-c.__Geometry.origin.offset.left,i=e.origin.offset.top-c.__Geometry.origin.offset.top;c._$tooltip.css({left:c.__lastPosition.coord.left+g,top:c.__lastPosition.coord.top+i})}}c._trigger({type:"scroll",event:b,geo:e})}return c},__stateSet:function(a){return this.__state=a,this._trigger({type:"state",state:a}),this},__timeoutsClear:function(){return clearTimeout(this.__timeouts.open),this.__timeouts.open=null,a.each(this.__timeouts.close,function(a,b){clearTimeout(b)}),this.__timeouts.close=[],this},__trackerStart:function(){var a=this,b=a._$tooltip.find(".tooltipster-content");return a.__options.trackTooltip&&(a.__contentBcr=b[0].getBoundingClientRect()),a.__tracker=setInterval(function(){if(d(a._$origin)&&d(a._$tooltip)){if(a.__options.trackOrigin){var e=a.__geometry(),f=!1;c(e.origin.size,a.__Geometry.origin.size)&&(a.__Geometry.origin.fixedLineage?c(e.origin.windowOffset,a.__Geometry.origin.windowOffset)&&(f=!0):c(e.origin.offset,a.__Geometry.origin.offset)&&(f=!0)),f||(a.__options.triggerClose.mouseleave?a._close():a.reposition())}if(a.__options.trackTooltip){var g=b[0].getBoundingClientRect();g.height===a.__contentBcr.height&&g.width===a.__contentBcr.width||(a.reposition(),a.__contentBcr=g)}}else a._close()},a.__options.trackerInterval),a},_close:function(b,c,d){var e=this,f=!0;if(e._trigger({type:"close",event:b,stop:function(){f=!1}}),f||d){c&&e.__callbacks.close.push(c),e.__callbacks.open=[],e.__timeoutsClear();var g=function(){a.each(e.__callbacks.close,function(a,c){c.call(e,e,{event:b,origin:e._$origin[0]})}),e.__callbacks.close=[]};if("closed"!=e.__state){var i=!0,j=new Date,k=j.getTime(),l=k+e.__options.animationDuration[1];if("disappearing"==e.__state&&l>e.__closingTime&&e.__options.animationDuration[1]>0&&(i=!1),i){e.__closingTime=l,"disappearing"!=e.__state&&e.__stateSet("disappearing");var m=function(){clearInterval(e.__tracker),e._trigger({type:"closing",event:b}),e._$tooltip.off("."+e.__namespace+"-triggerClose").removeClass("tooltipster-dying"),a(h.window).off("."+e.__namespace+"-triggerClose"),e.__$originParents.each(function(b,c){a(c).off("scroll."+e.__namespace+"-triggerClose")}),e.__$originParents=null,a(h.window.document.body).off("."+e.__namespace+"-triggerClose"),e._$origin.off("."+e.__namespace+"-triggerClose"),e._off("dismissable"),e.__stateSet("closed"),e._trigger({type:"after",event:b}),e.__options.functionAfter&&e.__options.functionAfter.call(e,e,{event:b,origin:e._$origin[0]}),g()};h.hasTransitions?(e._$tooltip.css({"-moz-animation-duration":e.__options.animationDuration[1]+"ms","-ms-animation-duration":e.__options.animationDuration[1]+"ms","-o-animation-duration":e.__options.animationDuration[1]+"ms","-webkit-animation-duration":e.__options.animationDuration[1]+"ms","animation-duration":e.__options.animationDuration[1]+"ms","transition-duration":e.__options.animationDuration[1]+"ms"}),e._$tooltip.clearQueue().removeClass("tooltipster-show").addClass("tooltipster-dying"),e.__options.animationDuration[1]>0&&e._$tooltip.delay(e.__options.animationDuration[1]),e._$tooltip.queue(m)):e._$tooltip.stop().fadeOut(e.__options.animationDuration[1],m)}}else g()}return e},_off:function(){return this.__$emitterPrivate.off.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_on:function(){return this.__$emitterPrivate.on.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_one:function(){return this.__$emitterPrivate.one.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_open:function(b,c){var e=this;if(!e.__destroying&&d(e._$origin)&&e.__enabled){var f=!0;if("closed"==e.__state&&(e._trigger({type:"before",event:b,stop:function(){f=!1}}),f&&e.__options.functionBefore&&(f=e.__options.functionBefore.call(e,e,{event:b,origin:e._$origin[0]}))),f!==!1&&null!==e.__Content){c&&e.__callbacks.open.push(c),e.__callbacks.close=[],e.__timeoutsClear();var g,i=function(){"stable"!=e.__state&&e.__stateSet("stable"),a.each(e.__callbacks.open,function(a,b){b.call(e,e,{origin:e._$origin[0],tooltip:e._$tooltip[0]})}),e.__callbacks.open=[]};if("closed"!==e.__state)g=0,"disappearing"===e.__state?(e.__stateSet("appearing"),h.hasTransitions?(e._$tooltip.clearQueue().removeClass("tooltipster-dying").addClass("tooltipster-show"),e.__options.animationDuration[0]>0&&e._$tooltip.delay(e.__options.animationDuration[0]),e._$tooltip.queue(i)):e._$tooltip.stop().fadeIn(i)):"stable"==e.__state&&i();else{if(e.__stateSet("appearing"),g=e.__options.animationDuration[0],e.__contentInsert(),e.reposition(b,!0),h.hasTransitions?(e._$tooltip.addClass("tooltipster-"+e.__options.animation).addClass("tooltipster-initial").css({"-moz-animation-duration":e.__options.animationDuration[0]+"ms","-ms-animation-duration":e.__options.animationDuration[0]+"ms","-o-animation-duration":e.__options.animationDuration[0]+"ms","-webkit-animation-duration":e.__options.animationDuration[0]+"ms","animation-duration":e.__options.animationDuration[0]+"ms","transition-duration":e.__options.animationDuration[0]+"ms"}),setTimeout(function(){"closed"!=e.__state&&(e._$tooltip.addClass("tooltipster-show").removeClass("tooltipster-initial"),e.__options.animationDuration[0]>0&&e._$tooltip.delay(e.__options.animationDuration[0]),e._$tooltip.queue(i))},0)):e._$tooltip.css("display","none").fadeIn(e.__options.animationDuration[0],i),e.__trackerStart(),a(h.window).on("resize."+e.__namespace+"-triggerClose",function(b){var c=a(document.activeElement);(c.is("input")||c.is("textarea"))&&a.contains(e._$tooltip[0],c[0])||e.reposition(b)}).on("scroll."+e.__namespace+"-triggerClose",function(a){e.__scrollHandler(a)}),e.__$originParents=e._$origin.parents(),e.__$originParents.each(function(b,c){a(c).on("scroll."+e.__namespace+"-triggerClose",function(a){e.__scrollHandler(a)})}),e.__options.triggerClose.mouseleave||e.__options.triggerClose.touchleave&&h.hasTouchCapability){e._on("dismissable",function(a){a.dismissable?a.delay?(m=setTimeout(function(){e._close(a.event)},a.delay),e.__timeouts.close.push(m)):e._close(a):clearTimeout(m)});var j=e._$origin,k="",l="",m=null;e.__options.interactive&&(j=j.add(e._$tooltip)),e.__options.triggerClose.mouseleave&&(k+="mouseenter."+e.__namespace+"-triggerClose ",l+="mouseleave."+e.__namespace+"-triggerClose "),e.__options.triggerClose.touchleave&&h.hasTouchCapability&&(k+="touchstart."+e.__namespace+"-triggerClose",l+="touchend."+e.__namespace+"-triggerClose touchcancel."+e.__namespace+"-triggerClose"),j.on(l,function(a){if(e._touchIsTouchEvent(a)||!e._touchIsEmulatedEvent(a)){var b="mouseleave"==a.type?e.__options.delay:e.__options.delayTouch;e._trigger({delay:b[1],dismissable:!0,event:a,type:"dismissable"})}}).on(k,function(a){!e._touchIsTouchEvent(a)&&e._touchIsEmulatedEvent(a)||e._trigger({dismissable:!1,event:a,type:"dismissable"})})}e.__options.triggerClose.originClick&&e._$origin.on("click."+e.__namespace+"-triggerClose",function(a){e._touchIsTouchEvent(a)||e._touchIsEmulatedEvent(a)||e._close(a)}),(e.__options.triggerClose.click||e.__options.triggerClose.tap&&h.hasTouchCapability)&&setTimeout(function(){if("closed"!=e.__state){var b="",c=a(h.window.document.body);e.__options.triggerClose.click&&(b+="click."+e.__namespace+"-triggerClose "),e.__options.triggerClose.tap&&h.hasTouchCapability&&(b+="touchend."+e.__namespace+"-triggerClose"),c.on(b,function(b){e._touchIsMeaningfulEvent(b)&&(e._touchRecordEvent(b),e.__options.interactive&&a.contains(e._$tooltip[0],b.target)||e._close(b))}),e.__options.triggerClose.tap&&h.hasTouchCapability&&c.on("touchstart."+e.__namespace+"-triggerClose",function(a){e._touchRecordEvent(a)})}},0),e._trigger("ready"),e.__options.functionReady&&e.__options.functionReady.call(e,e,{origin:e._$origin[0],tooltip:e._$tooltip[0]})}if(e.__options.timer>0){var m=setTimeout(function(){e._close()},e.__options.timer+g);e.__timeouts.close.push(m)}}}return e},_openShortly:function(a){var b=this,c=!0;if("stable"!=b.__state&&"appearing"!=b.__state&&!b.__timeouts.open&&(b._trigger({type:"start",event:a,stop:function(){c=!1}}),c)){var d=0==a.type.indexOf("touch")?b.__options.delayTouch:b.__options.delay;d[0]?b.__timeouts.open=setTimeout(function(){b.__timeouts.open=null,b.__pointerIsOverOrigin&&b._touchIsMeaningfulEvent(a)?(b._trigger("startend"),b._open(a)):b._trigger("startcancel")},d[0]):(b._trigger("startend"),b._open(a))}return b},_optionsExtract:function(b,c){var d=this,e=a.extend(!0,{},c),f=d.__options[b];return f||(f={},a.each(c,function(a,b){var c=d.__options[a];void 0!==c&&(f[a]=c)})),a.each(e,function(b,c){void 0!==f[b]&&("object"!=typeof c||c instanceof Array||null==c||"object"!=typeof f[b]||f[b]instanceof Array||null==f[b]?e[b]=f[b]:a.extend(e[b],f[b]))}),e},_plug:function(b){var c=a.tooltipster._plugin(b);if(!c)throw new Error('The "'+b+'" plugin is not defined');return c.instance&&a.tooltipster.__bridge(c.instance,this,c.name),this},_touchIsEmulatedEvent:function(a){for(var b=!1,c=(new Date).getTime(),d=this.__touchEvents.length-1;d>=0;d--){var e=this.__touchEvents[d];if(!(c-e.time<500))break;e.target===a.target&&(b=!0)}return b},_touchIsMeaningfulEvent:function(a){return this._touchIsTouchEvent(a)&&!this._touchSwiped(a.target)||!this._touchIsTouchEvent(a)&&!this._touchIsEmulatedEvent(a)},_touchIsTouchEvent:function(a){return 0==a.type.indexOf("touch")},_touchRecordEvent:function(a){return this._touchIsTouchEvent(a)&&(a.time=(new Date).getTime(),this.__touchEvents.push(a)),this},_touchSwiped:function(a){for(var b=!1,c=this.__touchEvents.length-1;c>=0;c--){var d=this.__touchEvents[c];if("touchmove"==d.type){b=!0;break}if("touchstart"==d.type&&a===d.target)break}return b},_trigger:function(){var b=Array.prototype.slice.apply(arguments);return"string"==typeof b[0]&&(b[0]={type:b[0]}),b[0].instance=this,b[0].origin=this._$origin?this._$origin[0]:null,b[0].tooltip=this._$tooltip?this._$tooltip[0]:null,this.__$emitterPrivate.trigger.apply(this.__$emitterPrivate,b),a.tooltipster._trigger.apply(a.tooltipster,b),this.__$emitterPublic.trigger.apply(this.__$emitterPublic,b),this},_unplug:function(b){var c=this;if(c[b]){var d=a.tooltipster._plugin(b);d.instance&&a.each(d.instance,function(a,d){c[a]&&c[a].bridged===c[b]&&delete c[a]}),c[b].__destroy&&c[b].__destroy(),delete c[b]}return c},close:function(a){return this.__destroyed?this.__destroyError():this._close(null,a),this},content:function(a){var b=this;if(void 0===a)return b.__Content;if(b.__destroyed)b.__destroyError();else if(b.__contentSet(a),null!==b.__Content){if("closed"!==b.__state&&(b.__contentInsert(),b.reposition(),b.__options.updateAnimation))if(h.hasTransitions){var c=b.__options.updateAnimation;b._$tooltip.addClass("tooltipster-update-"+c),setTimeout(function(){"closed"!=b.__state&&b._$tooltip.removeClass("tooltipster-update-"+c)},1e3)}else b._$tooltip.fadeTo(200,.5,function(){"closed"!=b.__state&&b._$tooltip.fadeTo(200,1)})}else b._close();return b},destroy:function(){var b=this;if(b.__destroyed)b.__destroyError();else{"closed"!=b.__state?b.option("animationDuration",0)._close(null,null,!0):b.__timeoutsClear(),b._trigger("destroy"),b.__destroyed=!0,b._$origin.removeData(b.__namespace).off("."+b.__namespace+"-triggerOpen"),a(h.window.document.body).off("."+b.__namespace+"-triggerOpen");var c=b._$origin.data("tooltipster-ns");if(c)if(1===c.length){var d=null;"previous"==b.__options.restoration?d=b._$origin.data("tooltipster-initialTitle"):"current"==b.__options.restoration&&(d="string"==typeof b.__Content?b.__Content:a("
    ").append(b.__Content).html()),d&&b._$origin.attr("title",d),b._$origin.removeClass("tooltipstered"),b._$origin.removeData("tooltipster-ns").removeData("tooltipster-initialTitle")}else c=a.grep(c,function(a,c){return a!==b.__namespace}),b._$origin.data("tooltipster-ns",c);b._trigger("destroyed"),b._off(),b.off(),b.__Content=null,b.__$emitterPrivate=null,b.__$emitterPublic=null,b.__options.parent=null,b._$origin=null,b._$tooltip=null,a.tooltipster.__instancesLatestArr=a.grep(a.tooltipster.__instancesLatestArr,function(a,c){return b!==a}),clearInterval(b.__garbageCollector)}return b},disable:function(){return this.__destroyed?(this.__destroyError(),this):(this._close(),this.__enabled=!1,this)},elementOrigin:function(){return this.__destroyed?void this.__destroyError():this._$origin[0]},elementTooltip:function(){return this._$tooltip?this._$tooltip[0]:null},enable:function(){return this.__enabled=!0,this},hide:function(a){return this.close(a)},instance:function(){return this},off:function(){return this.__destroyed||this.__$emitterPublic.off.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},on:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.on.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},one:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.one.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},open:function(a){return this.__destroyed?this.__destroyError():this._open(null,a),this},option:function(b,c){return void 0===c?this.__options[b]:(this.__destroyed?this.__destroyError():(this.__options[b]=c,this.__optionsFormat(),a.inArray(b,["trigger","triggerClose","triggerOpen"])>=0&&this.__prepareOrigin(),"selfDestruction"===b&&this.__prepareGC()),this)},reposition:function(a,b){var c=this;return c.__destroyed?c.__destroyError():"closed"!=c.__state&&d(c._$origin)&&(b||d(c._$tooltip))&&(b||c._$tooltip.detach(),c.__Geometry=c.__geometry(),c._trigger({type:"reposition",event:a,helper:{geo:c.__Geometry}})),c},show:function(a){return this.open(a)},status:function(){return{destroyed:this.__destroyed,enabled:this.__enabled,open:"closed"!==this.__state,state:this.__state}},triggerHandler:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.triggerHandler.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this}},a.fn.tooltipster=function(){var b=Array.prototype.slice.apply(arguments),c="You are using a single HTML element as content for several tooltips. You probably want to set the contentCloning option to TRUE.";if(0===this.length)return this;if("string"==typeof b[0]){var d="#*$~&";return this.each(function(){var e=a(this).data("tooltipster-ns"),f=e?a(this).data(e[0]):null;if(!f)throw new Error("You called Tooltipster's \""+b[0]+'" method on an uninitialized element');if("function"!=typeof f[b[0]])throw new Error('Unknown method "'+b[0]+'"');this.length>1&&"content"==b[0]&&(b[1]instanceof a||"object"==typeof b[1]&&null!=b[1]&&b[1].tagName)&&!f.__options.contentCloning&&f.__options.debug&&console.log(c);var g=f[b[0]](b[1],b[2]);return g!==f||"instance"===b[0]?(d=g,!1):void 0}),"#*$~&"!==d?d:this}a.tooltipster.__instancesLatestArr=[];var e=b[0]&&void 0!==b[0].multiple,g=e&&b[0].multiple||!e&&f.multiple,h=b[0]&&void 0!==b[0].content,i=h&&b[0].content||!h&&f.content,j=b[0]&&void 0!==b[0].contentCloning,k=j&&b[0].contentCloning||!j&&f.contentCloning,l=b[0]&&void 0!==b[0].debug,m=l&&b[0].debug||!l&&f.debug;return this.length>1&&(i instanceof a||"object"==typeof i&&null!=i&&i.tagName)&&!k&&m&&console.log(c),this.each(function(){var c=!1,d=a(this),e=d.data("tooltipster-ns"),f=null;e?g?c=!0:m&&(console.log("Tooltipster: one or more tooltips are already attached to the element below. Ignoring."),console.log(this)):c=!0,c&&(f=new a.Tooltipster(this,b[0]),e||(e=[]),e.push(f.__namespace),d.data("tooltipster-ns",e),d.data(f.__namespace,f),f.__options.functionInit&&f.__options.functionInit.call(f,f,{origin:this}),f._trigger("init")),a.tooltipster.__instancesLatestArr.push(f)}),this},b.prototype={__init:function(b){this.__$tooltip=b,this.__$tooltip.css({left:0,overflow:"hidden",position:"absolute",top:0}).find(".tooltipster-content").css("overflow","auto"),this.$container=a('
    ').append(this.__$tooltip).appendTo(h.window.document.body)},__forceRedraw:function(){var a=this.__$tooltip.parent();this.__$tooltip.detach(),this.__$tooltip.appendTo(a)},constrain:function(a,b){return this.constraints={width:a,height:b},this.__$tooltip.css({display:"block",height:"",overflow:"auto",width:a}),this},destroy:function(){this.__$tooltip.detach().find(".tooltipster-content").css({display:"",overflow:""}),this.$container.remove()},free:function(){return this.constraints=null,this.__$tooltip.css({display:"",height:"",overflow:"visible",width:""}),this},measure:function(){this.__forceRedraw();var a=this.__$tooltip[0].getBoundingClientRect(),b={size:{height:a.height||a.bottom-a.top,width:a.width||a.right-a.left}};if(this.constraints){var c=this.__$tooltip.find(".tooltipster-content"),d=this.__$tooltip.outerHeight(),e=c[0].getBoundingClientRect(),f={height:d<=this.constraints.height,width:a.width<=this.constraints.width&&e.width>=c[0].scrollWidth-1};b.fits=f.height&&f.width}return h.IE&&h.IE<=11&&b.size.width!==h.window.document.documentElement.clientWidth&&(b.size.width=Math.ceil(b.size.width)+1),b}};var j=navigator.userAgent.toLowerCase();-1!=j.indexOf("msie")?h.IE=parseInt(j.split("msie")[1]):-1!==j.toLowerCase().indexOf("trident")&&-1!==j.indexOf(" rv:11")?h.IE=11:-1!=j.toLowerCase().indexOf("edge/")&&(h.IE=parseInt(j.toLowerCase().split("edge/")[1]))}); +\ No newline at end of file ++/*! tooltipster v4.2.8 */!function(a,b){void 0===a&&void 0!==window&&(a=window),"function"==typeof define&&define.amd?define(["jquery"],function(a){return b(a)}):"object"==typeof module&&module.exports?module.exports=b(require("jquery")):b(a.jQuery)}(this,function(a){function b(a){this.$container,this.constraints=null,this.__$tooltip,this.__init(a)}function c(b,c){var d=!0;return a.each(b,function(a,e){if(void 0===c[a]||b[a]!==c[a])return d=!1,!1}),d}function d(b){var c=b.attr("id"),d=c?h.window.document.getElementById(c):null;return d?d===b[0]:a.contains(h.window.document.body,b[0])}function e(){if(!g)return!1;var a=g.document.body||g.document.documentElement,b=a.style,c="transition",d=["Moz","Webkit","Khtml","O","ms"];if("string"==typeof b[c])return!0;c=c.charAt(0).toUpperCase()+c.substr(1);for(var e=0;e0?e=c.__plugins[d]:a.each(c.__plugins,function(a,b){if(b.name.substring(b.name.length-d.length-1)=="."+d)return e=b,!1}),e}if(b.name.indexOf(".")<0)throw new Error("Plugins must be namespaced");return c.__plugins[b.name]=b,b.core&&c.__bridge(b.core,c,b.name),this},_trigger:function(){var a=Array.prototype.slice.apply(arguments);return"string"==typeof a[0]&&(a[0]={type:a[0]}),this.__$emitterPrivate.trigger.apply(this.__$emitterPrivate,a),this.__$emitterPublic.trigger.apply(this.__$emitterPublic,a),this},instances:function(b){var c=[],d=b||".tooltipstered";return a(d).each(function(){var b=a(this),d=b.data("tooltipster-ns");d&&a.each(d,function(a,d){c.push(b.data(d))})}),c},instancesLatest:function(){return this.__instancesLatestArr},off:function(){return this.__$emitterPublic.off.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},on:function(){return this.__$emitterPublic.on.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},one:function(){return this.__$emitterPublic.one.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},origins:function(b){var c=b?b+" ":"";return a(c+".tooltipstered").toArray()},setDefaults:function(b){return a.extend(f,b),this},triggerHandler:function(){return this.__$emitterPublic.triggerHandler.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this}},a.tooltipster=new i,a.Tooltipster=function(b,c){this.__callbacks={close:[],open:[]},this.__closingTime,this.__Content,this.__contentBcr,this.__destroyed=!1,this.__$emitterPrivate=a({}),this.__$emitterPublic=a({}),this.__enabled=!0,this.__garbageCollector,this.__Geometry,this.__lastPosition,this.__namespace="tooltipster-"+Math.round(1e6*Math.random()),this.__options,this.__$originParents,this.__pointerIsOverOrigin=!1,this.__previousThemes=[],this.__state="closed",this.__timeouts={close:[],open:null},this.__touchEvents=[],this.__tracker=null,this._$origin,this._$tooltip,this.__init(b,c)},a.Tooltipster.prototype={__init:function(b,c){var d=this;if(d._$origin=a(b),d.__options=a.extend(!0,{},f,c),d.__optionsFormat(),!h.IE||h.IE>=d.__options.IEmin){var e=null;if(void 0===d._$origin.data("tooltipster-initialTitle")&&(e=d._$origin.attr("title"),void 0===e&&(e=null),d._$origin.data("tooltipster-initialTitle",e)),null!==d.__options.content)d.__contentSet(d.__options.content);else{var g,i=d._$origin.attr("data-tooltip-content");i&&(g=a(i)),g&&g[0]?d.__contentSet(g.first()):d.__contentSet(e)}d._$origin.removeAttr("title").addClass("tooltipstered"),d.__prepareOrigin(),d.__prepareGC(),a.each(d.__options.plugins,function(a,b){d._plug(b)}),h.hasTouchCapability&&a(h.window.document.body).on("touchmove."+d.__namespace+"-triggerOpen",function(a){d._touchRecordEvent(a)}),d._on("created",function(){d.__prepareTooltip()})._on("repositioned",function(a){d.__lastPosition=a.position})}else d.__options.disabled=!0},__contentInsert:function(){var a=this,b=a._$tooltip.find(".tooltipster-content"),c=a.__Content,d=function(a){c=a};return a._trigger({type:"format",content:a.__Content,format:d}),a.__options.functionFormat&&(c=a.__options.functionFormat.call(a,a,{origin:a._$origin[0]},a.__Content)),"string"!=typeof c||a.__options.contentAsHTML?b.empty().append(c):b.text(c),a},__contentSet:function(b){return b instanceof a&&this.__options.contentCloning&&(b=b.clone(!0)),this.__Content=b,this._trigger({type:"updated",content:b}),this},__destroyError:function(){throw new Error("This tooltip has been destroyed and cannot execute your method call.")},__geometry:function(){var b=this,c=b._$origin,d=b._$origin.is("area");if(d){var e=b._$origin.parent().attr("name");c=a('img[usemap="#'+e+'"]')}var f=c[0].getBoundingClientRect(),g=a(h.window.document),i=a(h.window),j=c,k={available:{document:null,window:null},document:{size:{height:g.height(),width:g.width()}},window:{scroll:{left:h.window.scrollX||h.window.document.documentElement.scrollLeft,top:h.window.scrollY||h.window.document.documentElement.scrollTop},size:{height:i.height(),width:i.width()}},origin:{fixedLineage:!1,offset:{},size:{height:f.bottom-f.top,width:f.right-f.left},usemapImage:d?c[0]:null,windowOffset:{bottom:f.bottom,left:f.left,right:f.right,top:f.top}}};if(d){var l=b._$origin.attr("shape"),m=b._$origin.attr("coords");if(m&&(m=m.split(","),a.map(m,function(a,b){m[b]=parseInt(a)})),"default"!=l)switch(l){case"circle":var n=m[0],o=m[1],p=m[2],q=o-p,r=n-p;k.origin.size.height=2*p,k.origin.size.width=k.origin.size.height,k.origin.windowOffset.left+=r,k.origin.windowOffset.top+=q;break;case"rect":var s=m[0],t=m[1],u=m[2],v=m[3];k.origin.size.height=v-t,k.origin.size.width=u-s,k.origin.windowOffset.left+=s,k.origin.windowOffset.top+=t;break;case"poly":for(var w=0,x=0,y=0,z=0,A="even",B=0;By&&(y=C,0===B&&(w=y)),Cz&&(z=C,1==B&&(x=z)),C6e4}),d(b._$origin)||b.close(function(){b.destroy()})},2e4):clearInterval(b.__garbageCollector),b},__prepareOrigin:function(){var a=this;if(a._$origin.off("."+a.__namespace+"-triggerOpen"),h.hasTouchCapability&&a._$origin.on("touchstart."+a.__namespace+"-triggerOpen touchend."+a.__namespace+"-triggerOpen touchcancel."+a.__namespace+"-triggerOpen",function(b){a._touchRecordEvent(b)}),a.__options.triggerOpen.click||a.__options.triggerOpen.tap&&h.hasTouchCapability){var b="";a.__options.triggerOpen.click&&(b+="click."+a.__namespace+"-triggerOpen "),a.__options.triggerOpen.tap&&h.hasTouchCapability&&(b+="touchend."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){a._touchIsMeaningfulEvent(b)&&a._open(b)})}if(a.__options.triggerOpen.mouseenter||a.__options.triggerOpen.touchstart&&h.hasTouchCapability){var b="";a.__options.triggerOpen.mouseenter&&(b+="mouseenter."+a.__namespace+"-triggerOpen "),a.__options.triggerOpen.touchstart&&h.hasTouchCapability&&(b+="touchstart."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){!a._touchIsTouchEvent(b)&&a._touchIsEmulatedEvent(b)||(a.__pointerIsOverOrigin=!0,a._openShortly(b))})}if(a.__options.triggerClose.mouseleave||a.__options.triggerClose.touchleave&&h.hasTouchCapability){var b="";a.__options.triggerClose.mouseleave&&(b+="mouseleave."+a.__namespace+"-triggerOpen "),a.__options.triggerClose.touchleave&&h.hasTouchCapability&&(b+="touchend."+a.__namespace+"-triggerOpen touchcancel."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){a._touchIsMeaningfulEvent(b)&&(a.__pointerIsOverOrigin=!1)})}return a},__prepareTooltip:function(){var b=this,c=b.__options.interactive?"auto":"";return b._$tooltip.attr("id",b.__namespace).css({"pointer-events":c,zIndex:b.__options.zIndex}),a.each(b.__previousThemes,function(a,c){b._$tooltip.removeClass(c)}),a.each(b.__options.theme,function(a,c){b._$tooltip.addClass(c)}),b.__previousThemes=a.merge([],b.__options.theme),b},__scrollHandler:function(b){var c=this;if(c.__options.triggerClose.scroll)c._close(b);else if(d(c._$origin)&&d(c._$tooltip)){var e=null;if(b.target===h.window.document)c.__Geometry.origin.fixedLineage||c.__options.repositionOnScroll&&c.reposition(b);else{e=c.__geometry();var f=!1;if("fixed"!=c._$origin.css("position")&&c.__$originParents.each(function(b,d){var g=a(d),h=g.css("overflow-x"),i=g.css("overflow-y");if("visible"!=h||"visible"!=i){var j=d.getBoundingClientRect();if("visible"!=h&&(e.origin.windowOffset.leftj.right))return f=!0,!1;if("visible"!=i&&(e.origin.windowOffset.topj.bottom)&&(f=!c.__options.checkOverflowY||c.__options.checkOverflowY(e,j)))return!1}if("fixed"==g.css("position"))return!1}),f)c._$tooltip.css("visibility","hidden");else if(c._$tooltip.css("visibility","visible"),c.__options.repositionOnScroll)c.reposition(b);else{var g=e.origin.offset.left-c.__Geometry.origin.offset.left,i=e.origin.offset.top-c.__Geometry.origin.offset.top;c._$tooltip.css({left:c.__lastPosition.coord.left+g,top:c.__lastPosition.coord.top+i})}}c._trigger({type:"scroll",event:b,geo:e})}return c},__stateSet:function(a){return this.__state=a,this._trigger({type:"state",state:a}),this},__timeoutsClear:function(){return clearTimeout(this.__timeouts.open),this.__timeouts.open=null,a.each(this.__timeouts.close,function(a,b){clearTimeout(b)}),this.__timeouts.close=[],this},__trackerStart:function(){var a=this,b=a._$tooltip.find(".tooltipster-content");return a.__options.trackTooltip&&(a.__contentBcr=b[0].getBoundingClientRect()),a.__tracker=setInterval(function(){if(d(a._$origin)&&d(a._$tooltip)){if(a.__options.trackOrigin){var e=a.__geometry(),f=!1;c(e.origin.size,a.__Geometry.origin.size)&&(a.__Geometry.origin.fixedLineage?c(e.origin.windowOffset,a.__Geometry.origin.windowOffset)&&(f=!0):c(e.origin.offset,a.__Geometry.origin.offset)&&(f=!0)),f||(a.__options.triggerClose.mouseleave&&!a.__options.ignoreCloseOnScroll?a._close():a.reposition())}if(a.__options.trackTooltip){var g=b[0].getBoundingClientRect();g.height===a.__contentBcr.height&&g.width===a.__contentBcr.width||(a.reposition(),a.__contentBcr=g)}}else a._close()},a.__options.trackerInterval),a},_close:function(b,c,d){var e=this,f=!0;if(e._trigger({type:"close",event:b,stop:function(){f=!1}}),f||d){c&&e.__callbacks.close.push(c),e.__callbacks.open=[],e.__timeoutsClear();var g=function(){a.each(e.__callbacks.close,function(a,c){c.call(e,e,{event:b,origin:e._$origin[0]})}),e.__callbacks.close=[]};if("closed"!=e.__state){var i=!0,j=new Date,k=j.getTime(),l=k+e.__options.animationDuration[1];if("disappearing"==e.__state&&l>e.__closingTime&&e.__options.animationDuration[1]>0&&(i=!1),i){e.__closingTime=l,"disappearing"!=e.__state&&e.__stateSet("disappearing");var m=function(){clearInterval(e.__tracker),e._trigger({type:"closing",event:b}),e._$tooltip.off("."+e.__namespace+"-triggerClose").removeClass("tooltipster-dying"),a(h.window).off("."+e.__namespace+"-triggerClose"),e.__$originParents.each(function(b,c){a(c).off("scroll."+e.__namespace+"-triggerClose")}),e.__$originParents=null,a(h.window.document.body).off("."+e.__namespace+"-triggerClose"),e._$origin.off("."+e.__namespace+"-triggerClose"),e._off("dismissable"),e.__stateSet("closed"),e._trigger({type:"after",event:b}),e.__options.functionAfter&&e.__options.functionAfter.call(e,e,{event:b,origin:e._$origin[0]}),g()};h.hasTransitions?(e._$tooltip.css({"-moz-animation-duration":e.__options.animationDuration[1]+"ms","-ms-animation-duration":e.__options.animationDuration[1]+"ms","-o-animation-duration":e.__options.animationDuration[1]+"ms","-webkit-animation-duration":e.__options.animationDuration[1]+"ms","animation-duration":e.__options.animationDuration[1]+"ms","transition-duration":e.__options.animationDuration[1]+"ms"}),e._$tooltip.clearQueue().removeClass("tooltipster-show").addClass("tooltipster-dying"),e.__options.animationDuration[1]>0&&e._$tooltip.delay(e.__options.animationDuration[1]),e._$tooltip.queue(m)):e._$tooltip.stop().fadeOut(e.__options.animationDuration[1],m)}}else g()}return e},_off:function(){return this.__$emitterPrivate.off.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_on:function(){return this.__$emitterPrivate.on.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_one:function(){return this.__$emitterPrivate.one.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_open:function(b,c){var e=this;if(!e.__destroying&&d(e._$origin)&&e.__enabled){var f=!0;if("closed"==e.__state&&(e._trigger({type:"before",event:b,stop:function(){f=!1}}),f&&e.__options.functionBefore&&(f=e.__options.functionBefore.call(e,e,{event:b,origin:e._$origin[0]}))),f!==!1&&null!==e.__Content){c&&e.__callbacks.open.push(c),e.__callbacks.close=[],e.__timeoutsClear();var g,i=function(){"stable"!=e.__state&&e.__stateSet("stable"),a.each(e.__callbacks.open,function(a,b){b.call(e,e,{origin:e._$origin[0],tooltip:e._$tooltip[0]})}),e.__callbacks.open=[]};if("closed"!==e.__state)g=0,"disappearing"===e.__state?(e.__stateSet("appearing"),h.hasTransitions?(e._$tooltip.clearQueue().removeClass("tooltipster-dying").addClass("tooltipster-show"),e.__options.animationDuration[0]>0&&e._$tooltip.delay(e.__options.animationDuration[0]),e._$tooltip.queue(i)):e._$tooltip.stop().fadeIn(i)):"stable"==e.__state&&i();else{if(e.__stateSet("appearing"),g=e.__options.animationDuration[0],e.__contentInsert(),e.reposition(b,!0),h.hasTransitions?(e._$tooltip.addClass("tooltipster-"+e.__options.animation).addClass("tooltipster-initial").css({"-moz-animation-duration":e.__options.animationDuration[0]+"ms","-ms-animation-duration":e.__options.animationDuration[0]+"ms","-o-animation-duration":e.__options.animationDuration[0]+"ms","-webkit-animation-duration":e.__options.animationDuration[0]+"ms","animation-duration":e.__options.animationDuration[0]+"ms","transition-duration":e.__options.animationDuration[0]+"ms"}),setTimeout(function(){"closed"!=e.__state&&(e._$tooltip.addClass("tooltipster-show").removeClass("tooltipster-initial"),e.__options.animationDuration[0]>0&&e._$tooltip.delay(e.__options.animationDuration[0]),e._$tooltip.queue(i))},0)):e._$tooltip.css("display","none").fadeIn(e.__options.animationDuration[0],i),e.__trackerStart(),a(h.window).on("resize."+e.__namespace+"-triggerClose",function(b){var c=a(document.activeElement);(c.is("input")||c.is("textarea"))&&a.contains(e._$tooltip[0],c[0])||e.reposition(b)}).on("scroll."+e.__namespace+"-triggerClose",function(a){e.__scrollHandler(a)}),e.__$originParents=e._$origin.parents(),e.__$originParents.each(function(b,c){a(c).on("scroll."+e.__namespace+"-triggerClose",function(a){e.__scrollHandler(a)})}),e.__options.triggerClose.mouseleave||e.__options.triggerClose.touchleave&&h.hasTouchCapability){e._on("dismissable",function(a){a.dismissable?a.delay?(m=setTimeout(function(){e._close(a.event)},a.delay),e.__timeouts.close.push(m)):e._close(a):clearTimeout(m)});var j=e._$origin,k="",l="",m=null;e.__options.interactive&&(j=j.add(e._$tooltip)),e.__options.triggerClose.mouseleave&&(k+="mouseenter."+e.__namespace+"-triggerClose ",l+="mouseleave."+e.__namespace+"-triggerClose "),e.__options.triggerClose.touchleave&&h.hasTouchCapability&&(k+="touchstart."+e.__namespace+"-triggerClose",l+="touchend."+e.__namespace+"-triggerClose touchcancel."+e.__namespace+"-triggerClose"),j.on(l,function(a){if(e._touchIsTouchEvent(a)||!e._touchIsEmulatedEvent(a)){var b="mouseleave"==a.type?e.__options.delay:e.__options.delayTouch;e._trigger({delay:b[1],dismissable:!0,event:a,type:"dismissable"})}}).on(k,function(a){!e._touchIsTouchEvent(a)&&e._touchIsEmulatedEvent(a)||e._trigger({dismissable:!1,event:a,type:"dismissable"})})}e.__options.triggerClose.originClick&&e._$origin.on("click."+e.__namespace+"-triggerClose",function(a){e._touchIsTouchEvent(a)||e._touchIsEmulatedEvent(a)||e._close(a)}),(e.__options.triggerClose.click||e.__options.triggerClose.tap&&h.hasTouchCapability)&&setTimeout(function(){if("closed"!=e.__state){var b="",c=a(h.window.document.body);e.__options.triggerClose.click&&(b+="click."+e.__namespace+"-triggerClose "),e.__options.triggerClose.tap&&h.hasTouchCapability&&(b+="touchend."+e.__namespace+"-triggerClose"),c.on(b,function(b){e._touchIsMeaningfulEvent(b)&&(e._touchRecordEvent(b),e.__options.interactive&&a.contains(e._$tooltip[0],b.target)||e._close(b))}),e.__options.triggerClose.tap&&h.hasTouchCapability&&c.on("touchstart."+e.__namespace+"-triggerClose",function(a){e._touchRecordEvent(a)})}},0),e._trigger("ready"),e.__options.functionReady&&e.__options.functionReady.call(e,e,{origin:e._$origin[0],tooltip:e._$tooltip[0]})}if(e.__options.timer>0){var m=setTimeout(function(){e._close()},e.__options.timer+g);e.__timeouts.close.push(m)}}}return e},_openShortly:function(a){var b=this,c=!0;if("stable"!=b.__state&&"appearing"!=b.__state&&!b.__timeouts.open&&(b._trigger({type:"start",event:a,stop:function(){c=!1}}),c)){var d=0==a.type.indexOf("touch")?b.__options.delayTouch:b.__options.delay;d[0]?b.__timeouts.open=setTimeout(function(){b.__timeouts.open=null,b.__pointerIsOverOrigin&&b._touchIsMeaningfulEvent(a)?(b._trigger("startend"),b._open(a)):b._trigger("startcancel")},d[0]):(b._trigger("startend"),b._open(a))}return b},_optionsExtract:function(b,c){var d=this,e=a.extend(!0,{},c),f=d.__options[b];return f||(f={},a.each(c,function(a,b){var c=d.__options[a];void 0!==c&&(f[a]=c)})),a.each(e,function(b,c){void 0!==f[b]&&("object"!=typeof c||c instanceof Array||null==c||"object"!=typeof f[b]||f[b]instanceof Array||null==f[b]?e[b]=f[b]:a.extend(e[b],f[b]))}),e},_plug:function(b){var c=a.tooltipster._plugin(b);if(!c)throw new Error('The "'+b+'" plugin is not defined');return c.instance&&a.tooltipster.__bridge(c.instance,this,c.name),this},_touchIsEmulatedEvent:function(a){for(var b=!1,c=(new Date).getTime(),d=this.__touchEvents.length-1;d>=0;d--){var e=this.__touchEvents[d];if(!(c-e.time<500))break;e.target===a.target&&(b=!0)}return b},_touchIsMeaningfulEvent:function(a){return this._touchIsTouchEvent(a)&&!this._touchSwiped(a.target)||!this._touchIsTouchEvent(a)&&!this._touchIsEmulatedEvent(a)},_touchIsTouchEvent:function(a){return 0==a.type.indexOf("touch")},_touchRecordEvent:function(a){return this._touchIsTouchEvent(a)&&(a.time=(new Date).getTime(),this.__touchEvents.push(a)),this},_touchSwiped:function(a){for(var b=!1,c=this.__touchEvents.length-1;c>=0;c--){var d=this.__touchEvents[c];if("touchmove"==d.type){b=!0;break}if("touchstart"==d.type&&a===d.target)break}return b},_trigger:function(){var b=Array.prototype.slice.apply(arguments);return"string"==typeof b[0]&&(b[0]={type:b[0]}),b[0].instance=this,b[0].origin=this._$origin?this._$origin[0]:null,b[0].tooltip=this._$tooltip?this._$tooltip[0]:null,this.__$emitterPrivate.trigger.apply(this.__$emitterPrivate,b),a.tooltipster._trigger.apply(a.tooltipster,b),this.__$emitterPublic.trigger.apply(this.__$emitterPublic,b),this},_unplug:function(b){var c=this;if(c[b]){var d=a.tooltipster._plugin(b);d.instance&&a.each(d.instance,function(a,d){c[a]&&c[a].bridged===c[b]&&delete c[a]}),c[b].__destroy&&c[b].__destroy(),delete c[b]}return c},close:function(a){return this.__destroyed?this.__destroyError():this._close(null,a),this},content:function(a){var b=this;if(void 0===a)return b.__Content;if(b.__destroyed)b.__destroyError();else if(b.__contentSet(a),null!==b.__Content){if("closed"!==b.__state&&(b.__contentInsert(),b.reposition(),b.__options.updateAnimation))if(h.hasTransitions){var c=b.__options.updateAnimation;b._$tooltip.addClass("tooltipster-update-"+c),setTimeout(function(){"closed"!=b.__state&&b._$tooltip.removeClass("tooltipster-update-"+c)},1e3)}else b._$tooltip.fadeTo(200,.5,function(){"closed"!=b.__state&&b._$tooltip.fadeTo(200,1)})}else b._close();return b},destroy:function(){var b=this;if(b.__destroyed)b.__destroyError();else{"closed"!=b.__state?b.option("animationDuration",0)._close(null,null,!0):b.__timeoutsClear(),b._trigger("destroy"),b.__destroyed=!0,b._$origin.removeData(b.__namespace).off("."+b.__namespace+"-triggerOpen"),a(h.window.document.body).off("."+b.__namespace+"-triggerOpen");var c=b._$origin.data("tooltipster-ns");if(c)if(1===c.length){var d=null;"previous"==b.__options.restoration?d=b._$origin.data("tooltipster-initialTitle"):"current"==b.__options.restoration&&(d="string"==typeof b.__Content?b.__Content:a("
    ").append(b.__Content).html()),d&&b._$origin.attr("title",d),b._$origin.removeClass("tooltipstered"),b._$origin.removeData("tooltipster-ns").removeData("tooltipster-initialTitle")}else c=a.grep(c,function(a,c){return a!==b.__namespace}),b._$origin.data("tooltipster-ns",c);b._trigger("destroyed"),b._off(),b.off(),b.__Content=null,b.__$emitterPrivate=null,b.__$emitterPublic=null,b.__options.parent=null,b._$origin=null,b._$tooltip=null,a.tooltipster.__instancesLatestArr=a.grep(a.tooltipster.__instancesLatestArr,function(a,c){return b!==a}),clearInterval(b.__garbageCollector)}return b},disable:function(){return this.__destroyed?(this.__destroyError(),this):(this._close(),this.__enabled=!1,this)},elementOrigin:function(){return this.__destroyed?void this.__destroyError():this._$origin[0]},elementTooltip:function(){return this._$tooltip?this._$tooltip[0]:null},enable:function(){return this.__enabled=!0,this},hide:function(a){return this.close(a)},instance:function(){return this},off:function(){return this.__destroyed||this.__$emitterPublic.off.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},on:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.on.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},one:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.one.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},open:function(a){return this.__destroyed?this.__destroyError():this._open(null,a),this},option:function(b,c){return void 0===c?this.__options[b]:(this.__destroyed?this.__destroyError():(this.__options[b]=c,this.__optionsFormat(),a.inArray(b,["trigger","triggerClose","triggerOpen"])>=0&&this.__prepareOrigin(),"selfDestruction"===b&&this.__prepareGC()),this)},reposition:function(a,b){var c=this;return c.__destroyed?c.__destroyError():"closed"!=c.__state&&d(c._$origin)&&(b||d(c._$tooltip))&&(b||c._$tooltip.detach(),c.__Geometry=c.__geometry(),c._trigger({type:"reposition",event:a,helper:{geo:c.__Geometry}})),c},show:function(a){return this.open(a)},status:function(){return{destroyed:this.__destroyed,enabled:this.__enabled,open:"closed"!==this.__state,state:this.__state}},triggerHandler:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.triggerHandler.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this}},a.fn.tooltipster=function(){var b=Array.prototype.slice.apply(arguments),c="You are using a single HTML element as content for several tooltips. You probably want to set the contentCloning option to TRUE.";if(0===this.length)return this;if("string"==typeof b[0]){var d="#*$~&";return this.each(function(){var e=a(this).data("tooltipster-ns"),f=e?a(this).data(e[0]):null;if(!f)throw new Error("You called Tooltipster's \""+b[0]+'" method on an uninitialized element');if("function"!=typeof f[b[0]])throw new Error('Unknown method "'+b[0]+'"');this.length>1&&"content"==b[0]&&(b[1]instanceof a||"object"==typeof b[1]&&null!=b[1]&&b[1].tagName)&&!f.__options.contentCloning&&f.__options.debug&&console.log(c);var g=f[b[0]](b[1],b[2]);if(g!==f||"instance"===b[0])return d=g,!1}),"#*$~&"!==d?d:this}a.tooltipster.__instancesLatestArr=[];var e=b[0]&&void 0!==b[0].multiple,g=e&&b[0].multiple||!e&&f.multiple,h=b[0]&&void 0!==b[0].content,i=h&&b[0].content||!h&&f.content,j=b[0]&&void 0!==b[0].contentCloning,k=j&&b[0].contentCloning||!j&&f.contentCloning,l=b[0]&&void 0!==b[0].debug,m=l&&b[0].debug||!l&&f.debug;return this.length>1&&(i instanceof a||"object"==typeof i&&null!=i&&i.tagName)&&!k&&m&&console.log(c),this.each(function(){var c=!1,d=a(this),e=d.data("tooltipster-ns"),f=null;e?g?c=!0:m&&(console.log("Tooltipster: one or more tooltips are already attached to the element below. Ignoring."),console.log(this)):c=!0,c&&(f=new a.Tooltipster(this,b[0]),e||(e=[]),e.push(f.__namespace),d.data("tooltipster-ns",e),d.data(f.__namespace,f),f.__options.functionInit&&f.__options.functionInit.call(f,f,{origin:this}),f._trigger("init")),a.tooltipster.__instancesLatestArr.push(f)}),this},b.prototype={__init:function(b){this.__$tooltip=b,this.__$tooltip.css({left:0,overflow:"hidden",position:"absolute",top:0}).find(".tooltipster-content").css("overflow","auto"),this.$container=a('
    ').append(this.__$tooltip).appendTo(h.window.document.body)},__forceRedraw:function(){var a=this.__$tooltip.parent();this.__$tooltip.detach(),this.__$tooltip.appendTo(a)},constrain:function(a,b){return this.constraints={width:a,height:b},this.__$tooltip.css({display:"block",height:"",overflow:"auto",width:a}),this},destroy:function(){this.__$tooltip.detach().find(".tooltipster-content").css({display:"",overflow:""}),this.$container.remove()},free:function(){return this.constraints=null,this.__$tooltip.css({display:"",height:"",overflow:"visible",width:""}),this},measure:function(){this.__forceRedraw();var a=this.__$tooltip[0].getBoundingClientRect(),b={size:{height:a.height||a.bottom-a.top,width:a.width||a.right-a.left}};if(this.constraints){var c=this.__$tooltip.find(".tooltipster-content"),d=this.__$tooltip.outerHeight(),e=c[0].getBoundingClientRect(),f={height:d<=this.constraints.height,width:a.width<=this.constraints.width&&e.width>=c[0].scrollWidth-1};b.fits=f.height&&f.width}return h.IE&&h.IE<=11&&b.size.width!==h.window.document.documentElement.clientWidth&&(b.size.width=Math.ceil(b.size.width)+1),b}};var j=navigator.userAgent.toLowerCase();return j.indexOf("msie")!=-1?h.IE=parseInt(j.split("msie")[1]):j.toLowerCase().indexOf("trident")!==-1&&j.indexOf(" rv:11")!==-1?h.IE=11:j.toLowerCase().indexOf("edge/")!=-1&&(h.IE=parseInt(j.toLowerCase().split("edge/")[1])),a}); +\ No newline at end of file +diff --git a/node_modules/tooltipster/src/js/tooltipster.js b/node_modules/tooltipster/src/js/tooltipster.js +index bcf5abd..d22ed9e 100644 +--- a/node_modules/tooltipster/src/js/tooltipster.js ++++ b/node_modules/tooltipster/src/js/tooltipster.js +@@ -1238,8 +1238,14 @@ $.Tooltipster.prototype = { + if ( geo.origin.windowOffset.top < bcr.top + || geo.origin.windowOffset.bottom > bcr.bottom + ) { +- overflows = true; +- return false; ++ if (self.__options.checkOverflowY) { ++ overflows = self.__options.checkOverflowY(geo, bcr); ++ } else { ++ overflows = true; ++ } ++ if (overflows) { ++ return false; ++ } + } + } + } +@@ -1390,7 +1396,7 @@ $.Tooltipster.prototype = { + + // close the tooltip when using the mouseleave close trigger + // (see https://github.com/iamceege/tooltipster/pull/253) +- if (self.__options.triggerClose.mouseleave) { ++ if (self.__options.triggerClose.mouseleave && !self.__options.ignoreCloseOnScroll) { + self._close(); + } + else { diff --git a/ui-ngx/src/app/core/api/widget-api.models.ts b/ui-ngx/src/app/core/api/widget-api.models.ts index 3547391ed0..7d0f091739 100644 --- a/ui-ngx/src/app/core/api/widget-api.models.ts +++ b/ui-ngx/src/app/core/api/widget-api.models.ts @@ -90,10 +90,17 @@ export interface IWidgetUtils { getEntityDetailsPageURL: (id: string, entityType: EntityType) => string; } +export interface PlaceMapItemActionData { + action: WidgetAction; + additionalParams?: any; + afterPlaceItemCallback: ($event: Event, descriptor: WidgetAction, entityId?: EntityId, entityName?: string, + additionalParams?: any, entityLabel?: string) => void; +} + export interface WidgetActionsApi { actionDescriptorsBySourceId: {[sourceId: string]: Array}; getActionDescriptors: (actionSourceId: string) => Array; - handleWidgetAction: ($event: Event, descriptor: WidgetActionDescriptor, + handleWidgetAction: ($event: Event, descriptor: WidgetAction, entityId?: EntityId, entityName?: string, additionalParams?: any, entityLabel?: string) => void; onWidgetAction: ($event: Event, action: WidgetAction) => void; elementClick: ($event: Event) => void; @@ -106,6 +113,7 @@ export interface WidgetActionsApi { hideDashboardToolbar?: boolean, preferredPlacement?: PopoverPlacement, hideOnClickOutside?: boolean, popoverWidth?: string, popoverHeight?: string, popoverStyle?: { [klass: string]: any }) => void; + placeMapItem: (action: PlaceMapItemActionData) => void; } export interface AliasInfo { diff --git a/ui-ngx/src/app/core/auth/auth.models.ts b/ui-ngx/src/app/core/auth/auth.models.ts index 55af791156..1944c693ac 100644 --- a/ui-ngx/src/app/core/auth/auth.models.ts +++ b/ui-ngx/src/app/core/auth/auth.models.ts @@ -28,6 +28,8 @@ export interface SysParamsState { userSettings: UserSettings; maxResourceSize: number; maxDebugModeDurationMinutes: number; + maxDataPointsPerRollingArg: number; + maxArgumentsPerCF: number; ruleChainDebugPerTenantLimitsConfiguration?: string; } diff --git a/ui-ngx/src/app/core/auth/auth.reducer.ts b/ui-ngx/src/app/core/auth/auth.reducer.ts index a460cf35bb..3ecf70074c 100644 --- a/ui-ngx/src/app/core/auth/auth.reducer.ts +++ b/ui-ngx/src/app/core/auth/auth.reducer.ts @@ -31,6 +31,8 @@ const emptyUserAuthState: AuthPayload = { persistDeviceStateToTelemetry: false, mobileQrEnabled: false, maxResourceSize: 0, + maxArgumentsPerCF: 0, + maxDataPointsPerRollingArg: 0, maxDebugModeDurationMinutes: 0, userSettings: initialUserSettings }; diff --git a/ui-ngx/src/app/core/http/calculated-fields.service.ts b/ui-ngx/src/app/core/http/calculated-fields.service.ts new file mode 100644 index 0000000000..66c0cb609e --- /dev/null +++ b/ui-ngx/src/app/core/http/calculated-fields.service.ts @@ -0,0 +1,61 @@ +/// +/// 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 { Injectable } from '@angular/core'; +import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { Observable } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; +import { PageData } from '@shared/models/page/page-data'; +import { CalculatedField, CalculatedFieldTestScriptInputParams } from '@shared/models/calculated-field.models'; +import { PageLink } from '@shared/models/page/page-link'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntityTestScriptResult } from '@shared/models/entity.models'; +import { CalculatedFieldEventBody } from '@shared/models/event.models'; + +@Injectable({ + providedIn: 'root' +}) +export class CalculatedFieldsService { + + constructor( + private http: HttpClient + ) { } + + public getCalculatedFieldById(calculatedFieldId: string, config?: RequestConfig): Observable { + return this.http.get(`/api/calculatedField/${calculatedFieldId}`, defaultHttpOptionsFromConfig(config)); + } + + public saveCalculatedField(calculatedField: CalculatedField, config?: RequestConfig): Observable { + return this.http.post('/api/calculatedField', calculatedField, defaultHttpOptionsFromConfig(config)); + } + + public deleteCalculatedField(calculatedFieldId: string, config?: RequestConfig): Observable { + return this.http.delete(`/api/calculatedField/${calculatedFieldId}`, defaultHttpOptionsFromConfig(config)); + } + + public getCalculatedFields({ entityType, id }: EntityId, pageLink: PageLink, config?: RequestConfig): Observable> { + return this.http.get>(`/api/${entityType}/${id}/calculatedFields${pageLink.toQuery()}`, + defaultHttpOptionsFromConfig(config)); + } + + public testScript(inputParams: CalculatedFieldTestScriptInputParams, config?: RequestConfig): Observable { + return this.http.post('/api/calculatedField/testScript', inputParams, defaultHttpOptionsFromConfig(config)); + } + + public getLatestCalculatedFieldDebugEvent(id: string, config?: RequestConfig): Observable { + return this.http.get(`/api/calculatedField/${id}/debug`, defaultHttpOptionsFromConfig(config)); + } +} diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index 7879e4d3bc..01fed28bf3 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -287,6 +287,11 @@ export class EntityService { case EntityType.OAUTH2_CLIENT: observable = this.oauth2Service.findTenantOAuth2ClientInfosByIds(entityIds, config); break; + case EntityType.RULE_CHAIN: + observable = this.getEntitiesByIdsObservable( + (id) => this.ruleChainService.getRuleChain(id, config), + entityIds); + break; } return observable; } diff --git a/ui-ngx/src/app/core/services/dynamic-component-factory.service.ts b/ui-ngx/src/app/core/services/dynamic-component-factory.service.ts index 3dd7f2a055..75bfa6b1e1 100644 --- a/ui-ngx/src/app/core/services/dynamic-component-factory.service.ts +++ b/ui-ngx/src/app/core/services/dynamic-component-factory.service.ts @@ -15,11 +15,10 @@ /// import { Component, Injectable, Type, ɵComponentDef, ɵNG_COMP_DEF } from '@angular/core'; -import { forkJoin, from, Observable, of } from 'rxjs'; +import { from, Observable, of } from 'rxjs'; import { CommonModule } from '@angular/common'; import { mergeMap } from 'rxjs/operators'; import { guid } from '@core/utils'; -import { getFlexLayoutModule } from '@shared/legacy/flex-layout.models'; @Injectable({ providedIn: 'root' @@ -35,9 +34,9 @@ export class DynamicComponentFactoryService { imports?: Type[], preserveWhitespaces?: boolean, styles?: string[]): Observable> { - return forkJoin({flexLayoutModule: getFlexLayoutModule(), compiler: from(import('@angular/compiler'))}).pipe( - mergeMap((data) => { - let componentImports: Type[] = [CommonModule, data.flexLayoutModule]; + return from(import('@angular/compiler')).pipe( + mergeMap(() => { + let componentImports: Type[] = [CommonModule]; if (imports) { componentImports = [...componentImports, ...imports]; } diff --git a/ui-ngx/src/app/core/services/item-buffer.service.ts b/ui-ngx/src/app/core/services/item-buffer.service.ts index 8806b15867..cef9bbe7a3 100644 --- a/ui-ngx/src/app/core/services/item-buffer.service.ts +++ b/ui-ngx/src/app/core/services/item-buffer.service.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { BreakpointId, Dashboard, DashboardLayoutId } from '@app/shared/models/dashboard.models'; -import { AliasesInfo, EntityAlias, EntityAliases, EntityAliasInfo } from '@shared/models/alias.models'; +import { AliasesInfo, EntityAlias, EntityAliases, EntityAliasInfo, getEntityAliasId } from '@shared/models/alias.models'; import { Datasource, DatasourceType, @@ -34,7 +34,8 @@ import { map } from 'rxjs/operators'; import { FcRuleNode, ruleNodeTypeDescriptors } from '@shared/models/rule-node.models'; import { RuleChainService } from '@core/http/rule-chain.service'; import { RuleChainImport } from '@shared/models/rule-chain.models'; -import { Filter, FilterInfo, Filters, FiltersInfo } from '@shared/models/query/query.models'; +import { Filter, FilterInfo, Filters, FiltersInfo, getFilterId } from '@shared/models/query/query.models'; +import { getWidgetExportDefinition } from '@shared/models/widget/widget-export.models'; const WIDGET_ITEM = 'widget_item'; const WIDGET_REFERENCE = 'widget_reference'; @@ -47,6 +48,7 @@ export interface WidgetItem { filtersInfo: FiltersInfo; originalSize: WidgetSize; originalColumns: number; + widgetExportInfo?: any; } export interface WidgetReference { @@ -139,12 +141,18 @@ export class ItemBufferService { } } } + let widgetExportInfo: any; + const exportDefinition = getWidgetExportDefinition(widget); + if (exportDefinition) { + widgetExportInfo = exportDefinition.prepareExportInfo(dashboard, widget); + } return { widget, aliasesInfo, filtersInfo, originalSize, - originalColumns + originalColumns, + widgetExportInfo }; } @@ -189,6 +197,7 @@ export class ItemBufferService { const filtersInfo = widgetItem.filtersInfo; const originalColumns = widgetItem.originalColumns; const originalSize = widgetItem.originalSize; + const widgetExportInfo = widgetItem.widgetExportInfo; let targetRow = -1; let targetColumn = -1; if (position) { @@ -199,7 +208,7 @@ export class ItemBufferService { return this.addWidgetToDashboard(targetDashboard, targetState, targetLayout, widget, aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, - originalColumns, originalSize, targetRow, targetColumn, breakpoint).pipe( + originalColumns, originalSize, targetRow, targetColumn, breakpoint, widgetExportInfo).pipe( map(() => widget) ); } else { @@ -248,7 +257,8 @@ export class ItemBufferService { originalSize: WidgetSize, row: number, column: number, - breakpoint = 'default'): Observable { + breakpoint = 'default', + widgetExportInfo?: any): Observable { let theDashboard: Dashboard; if (dashboard) { theDashboard = dashboard; @@ -258,26 +268,39 @@ export class ItemBufferService { theDashboard = this.dashboardUtils.validateAndUpdateDashboard(theDashboard); let callAliasUpdateFunction = false; let callFilterUpdateFunction = false; + let newEntityAliases: EntityAliases; + let newFilters: Filters; + const exportDefinition = getWidgetExportDefinition(widget); + if (exportDefinition && widgetExportInfo || aliasesInfo) { + newEntityAliases = deepClone(dashboard.configuration.entityAliases); + } + if (exportDefinition && widgetExportInfo || filtersInfo) { + newFilters = deepClone(dashboard.configuration.filters); + } if (aliasesInfo) { - const newEntityAliases = this.updateAliases(theDashboard, widget, aliasesInfo); - const aliasesUpdated = !isEqual(newEntityAliases, theDashboard.configuration.entityAliases); - if (aliasesUpdated) { - theDashboard.configuration.entityAliases = newEntityAliases; - if (onAliasesUpdateFunction) { - callAliasUpdateFunction = true; - } - } + this.updateAliases(widget, newEntityAliases, aliasesInfo); } if (filtersInfo) { - const newFilters = this.updateFilters(theDashboard, widget, filtersInfo); - const filtersUpdated = !isEqual(newFilters, theDashboard.configuration.filters); - if (filtersUpdated) { - theDashboard.configuration.filters = newFilters; - if (onFiltersUpdateFunction) { - callFilterUpdateFunction = true; - } + this.updateFilters(widget, newFilters, filtersInfo); + } + if (exportDefinition && widgetExportInfo) { + exportDefinition.updateFromExportInfo(widget, newEntityAliases, newFilters, widgetExportInfo); + } + const aliasesUpdated = newEntityAliases && !isEqual(newEntityAliases, theDashboard.configuration.entityAliases); + if (aliasesUpdated) { + theDashboard.configuration.entityAliases = newEntityAliases; + if (onAliasesUpdateFunction) { + callAliasUpdateFunction = true; } } + const filtersUpdated = newFilters && !isEqual(newFilters, theDashboard.configuration.filters); + if (filtersUpdated) { + theDashboard.configuration.filters = newFilters; + if (onFiltersUpdateFunction) { + callFilterUpdateFunction = true; + } + } + this.dashboardUtils.addWidgetToLayout(theDashboard, targetState, targetLayout, widget, originalColumns, originalSize, row, column, breakpoint); if (callAliasUpdateFunction) { @@ -430,14 +453,13 @@ export class ItemBufferService { }; } - private updateAliases(dashboard: Dashboard, widget: Widget, aliasesInfo: AliasesInfo): EntityAliases { - const entityAliases = deepClone(dashboard.configuration.entityAliases); + private updateAliases(widget: Widget, entityAliases: EntityAliases, aliasesInfo: AliasesInfo): void { let aliasInfo: EntityAliasInfo; let newAliasId: string; for (const datasourceIndexStr of Object.keys(aliasesInfo.datasourceAliases)) { const datasourceIndex = Number(datasourceIndexStr); aliasInfo = aliasesInfo.datasourceAliases[datasourceIndex]; - newAliasId = this.getEntityAliasId(entityAliases, aliasInfo); + newAliasId = getEntityAliasId(entityAliases, aliasInfo); if (widget.type === widgetType.alarm) { widget.config.alarmSource.entityAliasId = newAliasId; } else { @@ -446,7 +468,7 @@ export class ItemBufferService { } if (aliasesInfo.targetDeviceAlias) { aliasInfo = aliasesInfo.targetDeviceAlias; - newAliasId = this.getEntityAliasId(entityAliases, aliasInfo); + newAliasId = getEntityAliasId(entityAliases, aliasInfo); if (widget.config.targetDevice?.type !== TargetDeviceType.entity) { widget.config.targetDevice = { type: TargetDeviceType.entity @@ -454,101 +476,21 @@ export class ItemBufferService { } widget.config.targetDevice.entityAliasId = newAliasId; } - return entityAliases; } - private updateFilters(dashboard: Dashboard, widget: Widget, filtersInfo: FiltersInfo): Filters { - const filters = deepClone(dashboard.configuration.filters); + private updateFilters(widget: Widget, filters: Filters, filtersInfo: FiltersInfo): void { let filterInfo: FilterInfo; let newFilterId: string; for (const datasourceIndexStr of Object.keys(filtersInfo.datasourceFilters)) { const datasourceIndex = Number(datasourceIndexStr); filterInfo = filtersInfo.datasourceFilters[datasourceIndex]; - newFilterId = this.getFilterId(filters, filterInfo); + newFilterId = getFilterId(filters, filterInfo); if (widget.type === widgetType.alarm) { widget.config.alarmSource.filterId = newFilterId; } else { widget.config.datasources[datasourceIndex].filterId = newFilterId; } } - return filters; - } - - private isEntityAliasEqual(alias1: EntityAliasInfo, alias2: EntityAliasInfo): boolean { - return isEqual(alias1.filter, alias2.filter); - } - - private getEntityAliasId(entityAliases: EntityAliases, aliasInfo: EntityAliasInfo): string { - let newAliasId: string; - for (const aliasId of Object.keys(entityAliases)) { - if (this.isEntityAliasEqual(entityAliases[aliasId], aliasInfo)) { - newAliasId = aliasId; - break; - } - } - if (!newAliasId) { - const newAliasName = this.createEntityAliasName(entityAliases, aliasInfo.alias); - newAliasId = this.utils.guid(); - entityAliases[newAliasId] = {id: newAliasId, alias: newAliasName, filter: aliasInfo.filter}; - } - return newAliasId; - } - - private createEntityAliasName(entityAliases: EntityAliases, alias: string): string { - let c = 0; - let newAlias = alias; - let unique = false; - while (!unique) { - unique = true; - for (const entAliasId of Object.keys(entityAliases)) { - const entAlias = entityAliases[entAliasId]; - if (newAlias === entAlias.alias) { - c++; - newAlias = alias + c; - unique = false; - } - } - } - return newAlias; - } - - private isFilterEqual(filter1: FilterInfo, filter2: FilterInfo): boolean { - return isEqual(filter1.keyFilters, filter2.keyFilters); - } - - private getFilterId(filters: Filters, filterInfo: FilterInfo): string { - let newFilterId: string; - for (const filterId of Object.keys(filters)) { - if (this.isFilterEqual(filters[filterId], filterInfo)) { - newFilterId = filterId; - break; - } - } - if (!newFilterId) { - const newFilterName = this.createFilterName(filters, filterInfo.filter); - newFilterId = this.utils.guid(); - filters[newFilterId] = {id: newFilterId, filter: newFilterName, - keyFilters: filterInfo.keyFilters, editable: filterInfo.editable}; - } - return newFilterId; - } - - private createFilterName(filters: Filters, filter: string): string { - let c = 0; - let newFilter = filter; - let unique = false; - while (!unique) { - unique = true; - for (const entFilterId of Object.keys(filters)) { - const entFilter = filters[entFilterId]; - if (newFilter === entFilter.filter) { - c++; - newFilter = filter + c; - unique = false; - } - } - } - return newFilter; } private storeSet(key: string, elem: any) { diff --git a/ui-ngx/src/app/core/services/resources.service.ts b/ui-ngx/src/app/core/services/resources.service.ts index e884fb59ab..a50f445266 100644 --- a/ui-ngx/src/app/core/services/resources.service.ts +++ b/ui-ngx/src/app/core/services/resources.service.ts @@ -38,7 +38,6 @@ import { selectIsAuthenticated } from '@core/auth/auth.selectors'; import { AppState } from '@core/core.state'; import { map, tap } from 'rxjs/operators'; import { RequestConfig } from '@core/http/http-utils'; -import { getFlexLayoutModule } from '@app/shared/legacy/flex-layout.models'; import { isJSResource, removeTbResourcePrefix } from '@shared/models/resource.models'; export interface ModuleInfo { @@ -206,42 +205,25 @@ export class ResourcesService { const subject = new ReplaySubject(); this.loadedModulesWithComponents[url] = subject; - forkJoin( - [ - modulesMap.init(), - from(import('@angular/compiler')) - ] - ).subscribe( + forkJoin([ + modulesMap.init(), + from(import('@angular/compiler')) + ]).subscribe( () => { // @ts-ignore System.import(url, undefined, meta).then( (module: any) => { try { const modulesWithComponents = this.extractModulesWithComponents(module); - this.patchModulesWithFlexLayout(modulesWithComponents).subscribe( - { - next: modules => { - if (modules.modules.length || modules.standaloneComponents.length) { - try { - for (const module of modules.modules) { - createNgModule(module.module.type, this.injector); - } - this.loadedModulesWithComponents[url].next(modulesWithComponents); - this.loadedModulesWithComponents[url].complete(); - } catch (e) { - console.log(`Unable to parse module from url: ${url}`, e); - this.loadedModulesWithComponents[url].error(new Error(`Unable to parse module from url: ${url}`)); - } - } else { - this.loadedModulesWithComponents[url].error(new Error(`Module '${url}' doesn't have exported modules or components!`)); - } - }, - error: err => { - console.log(`Unable to patch module with flexLayout, module url: ${url}`, err); - this.loadedModulesWithComponents[url].error(new Error(`Unable to patch module with flexLayout, module url: ${url}`)); - } + if (modulesWithComponents.modules.length || modulesWithComponents.standaloneComponents.length) { + for (const module of modulesWithComponents.modules) { + createNgModule(module.module.type, this.injector); } - ); + this.loadedModulesWithComponents[url].next(modulesWithComponents); + this.loadedModulesWithComponents[url].complete(); + } else { + this.loadedModulesWithComponents[url].error(new Error(`Module '${url}' doesn't have exported modules or components!`)); + } } catch (e) { console.log(`Unable to parse module from url: ${url}`, e); this.loadedModulesWithComponents[url].error(new Error(`Unable to parse module from url: ${url}`)); @@ -343,40 +325,6 @@ export class ResourcesService { return modulesWithComponents; } - private patchModulesWithFlexLayout(modulesWithComponents: ModulesWithComponents): Observable { - return getFlexLayoutModule().pipe( - map((flexLayoutModule) => { - modulesWithComponents.modules.forEach(m => { - if (Array.isArray(m.module.imports)) { - if (!m.module.imports.includes(flexLayoutModule)) { - m.module.imports.push(flexLayoutModule); - } - } else { - const imports = m.module.imports(); - if (!imports.includes(flexLayoutModule)) { - imports.push(flexLayoutModule); - m.module.imports = imports; - } - } - }); - modulesWithComponents.standaloneComponents.forEach(c => { - if (Array.isArray(c.dependencies)) { - if (!c.dependencies.includes(flexLayoutModule)) { - c.dependencies.push(flexLayoutModule); - } - } else { - const dependencies = c.dependencies(); - if (!dependencies.includes(flexLayoutModule)) { - dependencies.push(flexLayoutModule); - c.dependencies = dependencies; - } - } - }); - return modulesWithComponents; - }) - ); - } - private loadResourceByType(type: 'css' | 'js', url: string): Observable { const subject = new ReplaySubject(); this.loadedResources[url] = subject; diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index 3096054a67..c37cae599e 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -15,9 +15,9 @@ /// import _ from 'lodash'; -import { from, Observable, of, ReplaySubject, Subject } from 'rxjs'; +import { from, isObservable, Observable, of, ReplaySubject, Subject } from 'rxjs'; import { catchError, finalize, share } from 'rxjs/operators'; -import { Datasource, DatasourceData, FormattedData, ReplaceInfo } from '@app/shared/models/widget.models'; +import { DataKey, Datasource, DatasourceData, FormattedData, ReplaceInfo } from '@app/shared/models/widget.models'; import { EntityId } from '@shared/models/id/entity-id'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { baseDetailsPageByEntityType, EntityType } from '@shared/models/entity-type.models'; @@ -331,6 +331,10 @@ export function deepClone(target: T, ignoreFields?: string[]): T { if (target === null) { return target; } + // Observables can't be cloned using the spread operator, because they have non-enumerable methods (like .pipe). + if (isObservable(target)) { + return target; + } if (target instanceof Date) { return new Date(target.getTime()) as any; } @@ -491,11 +495,12 @@ export const createLabelFromSubscriptionEntityInfo = (entityInfo: SubscriptionEn export const hasDatasourceLabelsVariables = (pattern: string): boolean => varsRegex.test(pattern) !== null; -export function formattedDataFormDatasourceData(input: DatasourceData[], dataIndex?: number, ts?: number): FormattedData[] { - return _(input).groupBy(el => el.datasource.entityName + el.datasource.entityType) +export function formattedDataFormDatasourceData(input: DatasourceData[], dataIndex?: number, ts?: number, + groupFunction: (el: DatasourceData) => any = (el) => el.datasource.entityName + el.datasource.entityType): FormattedData[] { + return _(input).groupBy(groupFunction) .values().value().map((entityArray, i) => { - const datasource = entityArray[0].datasource; - const obj = formattedDataFromDatasource(datasource, i); + const datasource = entityArray[0].datasource as D; + const obj = formattedDataFromDatasource(datasource, i); entityArray.filter(el => el.data.length).forEach(el => { const index = isDefined(dataIndex) ? dataIndex : el.data.length - 1; const dataSet = isDefined(ts) ? el.data.find(data => data[0] === ts) : el.data[index]; @@ -511,18 +516,20 @@ export function formattedDataFormDatasourceData(input: DatasourceData[], dataInd }); } -export function formattedDataArrayFromDatasourceData(input: DatasourceData[]): FormattedData[][] { - return _(input).groupBy(el => el.datasource.entityName) +export function formattedDataArrayFromDatasourceData(input: DatasourceData[], + groupFunction: (el: DatasourceData) => any = + (el) => el.datasource.entityName + el.datasource.entityType): FormattedData[][] { + return _(input).groupBy(groupFunction) .values().value().map((entityArray, dsIndex) => { - const timeDataMap: {[time: number]: FormattedData} = {}; + const timeDataMap: {[time: number]: FormattedData} = {}; entityArray.filter(e => e.data.length).forEach(entity => { entity.data.forEach(tsData => { const time = tsData[0]; const value = tsData[1]; let data = timeDataMap[time]; if (!data) { - const datasource = entity.datasource; - data = formattedDataFromDatasource(datasource, dsIndex); + const datasource = entity.datasource as D; + data = formattedDataFromDatasource(datasource, dsIndex); data.time = time; timeDataMap[time] = data; } @@ -537,7 +544,7 @@ export function formattedDataArrayFromDatasourceData(input: DatasourceData[]): F }); } -export function formattedDataFromDatasource(datasource: Datasource, dsIndex: number): FormattedData { +export function formattedDataFromDatasource(datasource: D, dsIndex: number): FormattedData { return { entityName: datasource.entityName, deviceName: datasource.entityName, @@ -845,7 +852,7 @@ function prepareMessageFromData(data): string { } } -export function genNextLabel(name: string, datasources: Datasource[]): string { +export const genNextLabel = (name: string, datasources: Datasource[]): string => { let label = name; let i = 1; let matches = false; @@ -879,6 +886,25 @@ export function genNextLabel(name: string, datasources: Datasource[]): string { return label; } +export const genNextLabelForDataKeys = (name: string, dataKeys: DataKey[]): string => { + let label = name; + let i = 1; + let matches = false; + if (dataKeys) { + do { + matches = false; + dataKeys.forEach((dataKey) => { + if (dataKey?.label === label) { + i++; + label = name + ' ' + i; + matches = true; + } + }); + } while (matches) + } + return label; +} + export const getOS = (): string => { const userAgent = window.navigator.userAgent.toLowerCase(); const macosPlatforms = /(macintosh|macintel|macppc|mac68k|macos|mac_powerpc)/i; diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index 6ba51ee872..70432e8cdf 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -231,10 +231,10 @@ import * as EntityFilterViewComponent from '@home/components/entity/entity-filte import * as EntityAliasDialogComponent from '@home/components/alias/entity-alias-dialog.component'; import * as EntityFilterComponent from '@home/components/entity/entity-filter.component'; import * as RelationFiltersComponent from '@home/components/relation/relation-filters.component'; -import * as EntityAliasSelectComponent from '@home/components/alias/entity-alias-select.component'; -import * as DataKeysComponent from '@home/components/widget/config/data-keys.component'; -import * as DataKeyConfigDialogComponent from '@home/components/widget/config/data-key-config-dialog.component'; -import * as DataKeyConfigComponent from '@home/components/widget/config/data-key-config.component'; +import * as EntityAliasSelectComponent from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component'; +import * as DataKeysComponent from '@home/components/widget/lib/settings/common/key/data-keys.component'; +import * as DataKeyConfigDialogComponent from '@home/components/widget/lib/settings/common/key/data-key-config-dialog.component'; +import * as DataKeyConfigComponent from '@home/components/widget/lib/settings/common/key/data-key-config.component'; import * as LegendConfigComponent from '@home/components/widget/lib/settings/common/legend-config.component'; import * as ManageWidgetActionsComponent from '@home/components/widget/action/manage-widget-actions.component'; import * as WidgetActionDialogComponent from '@home/components/widget/action/widget-action-dialog.component'; @@ -268,7 +268,7 @@ import * as ComplexFilterPredicateDialogComponent from '@home/components/filter/ import * as KeyFilterDialogComponent from '@home/components/filter/key-filter-dialog.component'; import * as FiltersDialogComponent from '@home/components/filter/filters-dialog.component'; import * as FilterDialogComponent from '@home/components/filter/filter-dialog.component'; -import * as FilterSelectComponent from '@home/components/filter/filter-select.component'; +import * as FilterSelectComponent from '@home/components/widget/lib/settings/common/filter/filter-select.component'; import * as FiltersEditComponent from '@home/components/filter/filters-edit.component'; import * as FiltersEditPanelComponent from '@home/components/filter/filters-edit-panel.component'; import * as UserFilterDialogComponent from '@home/components/filter/user-filter-dialog.component'; @@ -338,8 +338,7 @@ import * as AggregationOptionsConfigComponent from '@shared/components/time/aggr import * as IntervalOptionsConfigPanelComponent from '@shared/components/time/interval-options-config-panel.component'; import { IModulesMap } from '@modules/common/modules-map.models'; -import { Observable, map, of } from 'rxjs'; -import { getFlexLayout } from '@shared/legacy/flex-layout.models'; +import { Observable, of } from 'rxjs'; import { isJSResourceUrl } from '@shared/public-api'; class ModulesMap implements IModulesMap { @@ -352,10 +351,6 @@ class ModulesMap implements IModulesMap { '@angular/common': AngularCommon, '@angular/common/http': HttpClientModule, '@angular/forms': AngularForms, - '@angular/flex-layout': {}, - '@angular/flex-layout/flex': {}, - '@angular/flex-layout/grid': {}, - '@angular/flex-layout/extended': {}, '@angular/platform-browser': AngularPlatformBrowser, '@angular/platform-browser/animations': AngularPlatformBrowserAnimations, '@angular/router': AngularRouter, @@ -577,10 +572,10 @@ class ModulesMap implements IModulesMap { '@home/components/alias/entity-alias-dialog.component': EntityAliasDialogComponent, '@home/components/entity/entity-filter.component': EntityFilterComponent, '@home/components/relation/relation-filters.component': RelationFiltersComponent, - '@home/components/alias/entity-alias-select.component': EntityAliasSelectComponent, - '@home/components/widget/config/data-keys.component': DataKeysComponent, - '@home/components/widget/config/data-key-config-dialog.component': DataKeyConfigDialogComponent, - '@home/components/widget/config/data-key-config.component': DataKeyConfigComponent, + '@home/components/widget/lib/settings/common/alias/entity-alias-select.component': EntityAliasSelectComponent, + '@home/components/widget/lib/settings/common/key/data-keys.component': DataKeysComponent, + '@home/components/widget/lib/settings/common/key/data-key-config-dialog.component': DataKeyConfigDialogComponent, + '@home/components/widget/lib/settings/common/key/data-key-config.component': DataKeyConfigComponent, '@home/components/widget/lib/settings/common/legend-config.component': LegendConfigComponent, '@home/components/widget/action/manage-widget-actions.component': ManageWidgetActionsComponent, '@home/components/widget/action/widget-action-dialog.component': WidgetActionDialogComponent, @@ -610,7 +605,7 @@ class ModulesMap implements IModulesMap { '@home/components/filter/key-filter-dialog.component': KeyFilterDialogComponent, '@home/components/filter/filters-dialog.component': FiltersDialogComponent, '@home/components/filter/filter-dialog.component': FilterDialogComponent, - '@home/components/filter/filter-select.component': FilterSelectComponent, + '@home/components/widget/lib/settings/common/filter/filter-select.component': FilterSelectComponent, '@home/components/filter/filters-edit.component': FiltersEditComponent, '@home/components/filter/filters-edit-panel.component': FiltersEditPanelComponent, '@home/components/filter/user-filter-dialog.component': UserFilterDialogComponent, @@ -678,39 +673,30 @@ class ModulesMap implements IModulesMap { init(): Observable { if (!this.initialized) { - return getFlexLayout().pipe( - map((flexLayout) => { - this.modulesMap['@angular/flex-layout'] = flexLayout; - this.modulesMap['@angular/flex-layout/flex'] = flexLayout; - this.modulesMap['@angular/flex-layout/grid'] = flexLayout; - this.modulesMap['@angular/flex-layout/extended'] = flexLayout; - System.constructor.prototype.resolve = (id: string) => { - try { - if (this.modulesMap[id]) { - return 'app:' + id; - } else { - return id; - } - } catch (err) { - return id; - } - }; - for (const moduleId of Object.keys(this.modulesMap)) { - System.set('app:' + moduleId, this.modulesMap[moduleId]); + System.constructor.prototype.resolve = (id: string) => { + try { + if (this.modulesMap[id]) { + return 'app:' + id; + } else { + return id; } - System.constructor.prototype.shouldFetch = (url: string) => url.endsWith('/download') || isJSResourceUrl(url); - System.constructor.prototype.fetch = (url: string, options: RequestInit & {meta?: any}) => { - if (options?.meta?.additionalHeaders) { - options.headers = { ...options.headers, ...options.meta.additionalHeaders }; - } - return fetch(url, options); - }; - this.initialized = true; - }) - ); - } else { - return of(null); + } catch (err) { + return id; + } + }; + for (const moduleId of Object.keys(this.modulesMap)) { + System.set('app:' + moduleId, this.modulesMap[moduleId]); + } + System.constructor.prototype.shouldFetch = (url: string) => url.endsWith('/download') || isJSResourceUrl(url); + System.constructor.prototype.fetch = (url: string, options: RequestInit & {meta?: any}) => { + if (options?.meta?.additionalHeaders) { + options.headers = { ...options.headers, ...options.meta.additionalHeaders }; + } + return fetch(url, options); + }; + this.initialized = true; } + return of(null); } } diff --git a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.ts b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.ts index b8ff2b60e1..c3a9490792 100644 --- a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.ts @@ -93,6 +93,7 @@ export class AliasesEntitySelectComponent implements OnInit, OnDestroy { setTimeout(() => { this.updateDisplayValue(); this.updateEntityAliasesInfo(); + this.cd.detectChanges(); }, 0); } )); @@ -101,6 +102,7 @@ export class AliasesEntitySelectComponent implements OnInit, OnDestroy { setTimeout(() => { this.updateDisplayValue(); this.updateEntityAliasesInfo(); + this.cd.detectChanges(); }, 0); } )); @@ -184,7 +186,6 @@ export class AliasesEntitySelectComponent implements OnInit, OnDestroy { displayValue = this.translate.instant('entity.entities'); } this.displayValue = displayValue; - this.cd.detectChanges(); } private updateEntityAliasesInfo() { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts new file mode 100644 index 0000000000..1cf44966fc --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts @@ -0,0 +1,301 @@ +/// +/// 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 { + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { TranslateService } from '@ngx-translate/core'; +import { Direction } from '@shared/models/page/sort-order'; +import { MatDialog } from '@angular/material/dialog'; +import { PageLink } from '@shared/models/page/page-link'; +import { Observable, of } from 'rxjs'; +import { PageData } from '@shared/models/page/page-data'; +import { EntityId } from '@shared/models/id/entity-id'; +import { MINUTE } from '@shared/models/time/time.models'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { getCurrentAuthState, getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { DestroyRef, Renderer2 } from '@angular/core'; +import { EntityDebugSettings } from '@shared/models/entity.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { catchError, filter, switchMap, tap } from 'rxjs/operators'; +import { + ArgumentType, + CalculatedField, + CalculatedFieldEventArguments, + CalculatedFieldType, + CalculatedFieldTypeTranslations, + getCalculatedFieldArgumentsEditorCompleter, + getCalculatedFieldArgumentsHighlights, +} from '@shared/models/calculated-field.models'; +import { + CalculatedFieldDebugDialogComponent, CalculatedFieldDebugDialogData, + CalculatedFieldDialogComponent, + CalculatedFieldDialogData, + CalculatedFieldScriptTestDialogComponent, + CalculatedFieldTestScriptDialogData +} from './components/public-api'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { isObject } from '@core/utils'; +import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; +import { DatePipe } from '@angular/common'; + +export class CalculatedFieldsTableConfig extends EntityTableConfig { + + readonly calculatedFieldsDebugPerTenantLimitsConfiguration = + getCurrentAuthState(this.store)['calculatedFieldsDebugPerTenantLimitsConfiguration'] || '1:1'; + readonly maxDebugModeDuration = getCurrentAuthState(this.store).maxDebugModeDurationMinutes * MINUTE; + readonly tenantId = getCurrentAuthUser(this.store).tenantId; + additionalDebugActionConfig = { + title: this.translate.instant('calculated-fields.see-debug-events'), + action: (calculatedField: CalculatedField) => this.openDebugEventsDialog.call(this, calculatedField), + }; + + constructor(private calculatedFieldsService: CalculatedFieldsService, + private translate: TranslateService, + private dialog: MatDialog, + private datePipe: DatePipe, + public entityId: EntityId = null, + private store: Store, + private destroyRef: DestroyRef, + private renderer: Renderer2, + public entityName: string, + private importExportService: ImportExportService, + private entityDebugSettingsService: EntityDebugSettingsService, + ) { + super(); + this.tableTitle = this.translate.instant('entity.type-calculated-fields'); + this.detailsPanelEnabled = false; + this.pageMode = false; + this.entityType = EntityType.CALCULATED_FIELD; + this.entityTranslations = entityTypeTranslations.get(EntityType.CALCULATED_FIELD); + + this.entitiesFetchFunction = (pageLink: PageLink) => this.fetchCalculatedFields(pageLink); + this.addEntity = this.getCalculatedFieldDialog.bind(this); + this.deleteEntityTitle = (field: CalculatedField) => this.translate.instant('calculated-fields.delete-title', {title: field.name}); + this.deleteEntityContent = () => this.translate.instant('calculated-fields.delete-text'); + this.deleteEntitiesTitle = count => this.translate.instant('calculated-fields.delete-multiple-title', {count}); + this.deleteEntitiesContent = () => this.translate.instant('calculated-fields.delete-multiple-text'); + this.deleteEntity = id => this.calculatedFieldsService.deleteCalculatedField(id.id); + this.addActionDescriptors = [ + { + name: this.translate.instant('calculated-fields.create'), + icon: 'insert_drive_file', + isEnabled: () => true, + onAction: ($event) => this.getTable().addEntity($event) + }, + { + name: this.translate.instant('calculated-fields.import'), + icon: 'file_upload', + isEnabled: () => true, + onAction: () => this.importCalculatedField() + } + ]; + + this.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; + + const expressionColumn = new EntityTableColumn('expression', 'calculated-fields.expression', '300px'); + expressionColumn.sortable = false; + expressionColumn.cellContentFunction = entity => { + const expressionLabel = this.getExpressionLabel(entity); + return expressionLabel.length < 45 ? expressionLabel : `${expressionLabel.substring(0, 44)}…`; + } + expressionColumn.cellTooltipFunction = entity => { + const expressionLabel = this.getExpressionLabel(entity); + return expressionLabel.length < 45 ? null : expressionLabel + }; + + this.columns.push(new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px')); + this.columns.push(new EntityTableColumn('name', 'common.name', '33%')); + this.columns.push(new EntityTableColumn('type', 'common.type', '50px', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type)))); + this.columns.push(expressionColumn); + + this.cellActionDescriptors.push( + { + name: this.translate.instant('action.export'), + icon: 'file_download', + isEnabled: () => true, + onAction: (event$, entity) => this.exportCalculatedField(event$, entity), + }, + { + name: this.translate.instant('entity-view.events'), + icon: 'mdi:clipboard-text-clock', + isEnabled: () => true, + onAction: (_, entity) => this.openDebugEventsDialog(entity), + }, + { + name: '', + nameFunction: entity => this.entityDebugSettingsService.getDebugConfigLabel(entity?.debugSettings), + icon: 'mdi:bug', + isEnabled: () => true, + iconFunction: ({ debugSettings }) => this.entityDebugSettingsService.isDebugActive(debugSettings?.allEnabledUntil) || debugSettings?.failuresEnabled ? 'mdi:bug' : 'mdi:bug-outline', + onAction: ($event, entity) => this.onOpenDebugConfig($event, entity), + }, + { + name: this.translate.instant('action.edit'), + icon: 'edit', + isEnabled: () => true, + onAction: (_, entity) => this.editCalculatedField(entity), + } + ); + } + + private getExpressionLabel(entity: CalculatedField): string { + if (entity.type === CalculatedFieldType.SCRIPT) { + return 'function calculate(' + Object.keys(entity.configuration.arguments).join(', ') + ')'; + } else { + return entity.configuration.expression; + } + } + + fetchCalculatedFields(pageLink: PageLink): Observable> { + return this.calculatedFieldsService.getCalculatedFields(this.entityId, pageLink); + } + + onOpenDebugConfig($event: Event, calculatedField: CalculatedField): void { + const { debugSettings = {}, id } = calculatedField; + const additionalActionConfig = { + ...this.additionalDebugActionConfig, + action: () => this.openDebugEventsDialog(calculatedField) + }; + if ($event) { + $event.stopPropagation(); + } + + const { viewContainerRef, renderer } = this.entityDebugSettingsService; + if (!viewContainerRef || !renderer) { + this.entityDebugSettingsService.viewContainerRef = this.getTable().viewContainerRef; + this.entityDebugSettingsService.renderer = this.renderer; + } + + this.entityDebugSettingsService.openDebugStrategyPanel({ + debugSettings, + debugConfig: { + debugLimitsConfiguration: this.calculatedFieldsDebugPerTenantLimitsConfiguration, + maxDebugModeDuration: this.maxDebugModeDuration, + entityLabel: this.translate.instant('debug-settings.calculated-field'), + additionalActionConfig, + }, + onSettingsAppliedFn: settings => this.onDebugConfigChanged(id.id, settings) + }, $event.target as Element); + } + + private editCalculatedField(calculatedField: CalculatedField, isDirty = false): void { + this.getCalculatedFieldDialog(calculatedField, 'action.apply', isDirty) + .subscribe((res) => { + if (res) { + this.updateData(); + } + }); + } + + private getCalculatedFieldDialog(value?: CalculatedField, buttonTitle = 'action.add', isDirty = false): Observable { + return this.dialog.open(CalculatedFieldDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + value, + buttonTitle, + entityId: this.entityId, + debugLimitsConfiguration: this.calculatedFieldsDebugPerTenantLimitsConfiguration, + tenantId: this.tenantId, + entityName: this.entityName, + additionalDebugActionConfig: this.additionalDebugActionConfig, + getTestScriptDialogFn: this.getTestScriptDialog.bind(this), + isDirty, + }, + enterAnimationDuration: isDirty ? 0 : null, + }) + .afterClosed() + .pipe(filter(Boolean)); + } + + private openDebugEventsDialog(calculatedField: CalculatedField): void { + this.dialog.open(CalculatedFieldDebugDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + tenantId: this.tenantId, + value: calculatedField, + getTestScriptDialogFn: this.getTestScriptDialog.bind(this), + } + }) + .afterClosed() + .subscribe(); + } + + private exportCalculatedField($event: Event, calculatedField: CalculatedField): void { + if ($event) { + $event.stopPropagation(); + } + this.importExportService.exportCalculatedField(calculatedField.id.id); + } + + private importCalculatedField(): void { + this.importExportService.openCalculatedFieldImportDialog() + .pipe( + filter(Boolean), + switchMap(calculatedField => this.getCalculatedFieldDialog(calculatedField, 'action.add')), + filter(Boolean), + switchMap(calculatedField => this.calculatedFieldsService.saveCalculatedField(calculatedField)), + filter(Boolean), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.updateData()); + } + + private onDebugConfigChanged(id: string, debugSettings: EntityDebugSettings): void { + this.calculatedFieldsService.getCalculatedFieldById(id).pipe( + switchMap(field => this.calculatedFieldsService.saveCalculatedField({ ...field, debugSettings })), + catchError(() => of(null)), + takeUntilDestroyed(this.destroyRef), + ).subscribe(() => this.updateData()); + } + + private getTestScriptDialog(calculatedField: CalculatedField, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true): Observable { + const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { + const type = calculatedField.configuration.arguments[key].refEntityKey.type; + acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) + ? { ...argumentsObj[key], type } + : type === ArgumentType.Rolling ? { values: [], type } : { value: '', type, ts: new Date().getTime() }; + return acc; + }, {}); + return this.dialog.open(CalculatedFieldScriptTestDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'], + data: { + arguments: resultArguments, + expression: calculatedField.configuration.expression, + argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments), + argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments), + openCalculatedFieldEdit + } + }).afterClosed() + .pipe( + filter(Boolean), + tap(expression => { + if (openCalculatedFieldEdit) { + this.editCalculatedField({ entityId: this.entityId, ...calculatedField, configuration: {...calculatedField.configuration, expression } }, true) + } + }), + ); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.html new file mode 100644 index 0000000000..df433bc70e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.html @@ -0,0 +1,20 @@ + +@if (calculatedFieldsTableConfig) { + +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.scss new file mode 100644 index 0000000000..3feb1e7429 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.scss @@ -0,0 +1,22 @@ +/** + * 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. + */ +:host ::ng-deep { + tb-entities-table { + .mat-drawer-container { + background-color: white; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts new file mode 100644 index 0000000000..e10c4b301e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts @@ -0,0 +1,86 @@ +/// +/// 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 { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + effect, + input, + Renderer2, + ViewChild, +} from '@angular/core'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntitiesTableComponent } from '@home/components/entity/entities-table.component'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { CalculatedFieldsTableConfig } from '@home/components/calculated-fields/calculated-fields-table-config'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; +import { DatePipe } from '@angular/common'; + +@Component({ + selector: 'tb-calculated-fields-table', + templateUrl: './calculated-fields-table.component.html', + styleUrls: ['./calculated-fields-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [EntityDebugSettingsService] +}) +export class CalculatedFieldsTableComponent { + + @ViewChild(EntitiesTableComponent, {static: true}) entitiesTable: EntitiesTableComponent; + + active = input(); + entityId = input(); + entityName = input(); + + calculatedFieldsTableConfig: CalculatedFieldsTableConfig; + + constructor(private calculatedFieldsService: CalculatedFieldsService, + private translate: TranslateService, + private dialog: MatDialog, + private store: Store, + private datePipe: DatePipe, + private cd: ChangeDetectorRef, + private renderer: Renderer2, + private importExportService: ImportExportService, + private entityDebugSettingsService: EntityDebugSettingsService, + private destroyRef: DestroyRef) { + + effect(() => { + if (this.active()) { + this.calculatedFieldsTableConfig = new CalculatedFieldsTableConfig( + this.calculatedFieldsService, + this.translate, + this.dialog, + this.datePipe, + this.entityId(), + this.store, + this.destroyRef, + this.renderer, + this.entityName(), + this.importExportService, + this.entityDebugSettingsService, + ); + this.cd.markForCheck(); + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html new file mode 100644 index 0000000000..abb34cb502 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html @@ -0,0 +1,149 @@ + +
    +
    + + + +
    {{ 'common.name' | translate }}
    +
    + +
    +
    {{ argument.argumentName }}
    + +
    +
    +
    + + + {{ 'entity.entity-type' | translate }} + + +
    + @if (argument.refEntityId?.entityType === ArgumentEntityType.Tenant) { + {{ 'calculated-fields.argument-current-tenant' | translate }} + } @else if (argument.refEntityId?.id) { + {{ entityTypeTranslations.get(argument.refEntityId.entityType).type | translate }} + } @else { + {{ 'calculated-fields.argument-current' | translate }} + } +
    +
    +
    + + + {{ 'entity-view.target-entity' | translate }} + + +
    + @if (argument.refEntityId?.id) { + + {{ entityNameMap.get(argument.refEntityId.id) ?? '' }} + + } +
    +
    +
    + + + {{ 'common.type' | translate }} + + +
    {{ ArgumentTypeTranslations.get(argument.refEntityKey.type) | translate }}
    +
    +
    + + + {{ 'entity.key' | translate }} + + + +
    {{ argument.refEntityKey.key }}
    +
    +
    +
    + + + +
    + + +
    +
    +
    + + +
    +
    + {{ 'calculated-fields.no-arguments' | translate }} +
    + @if (errorText) { + + } +
    +
    + + @if (maxArgumentsPerCF && argumentsFormArray.length >= maxArgumentsPerCF) { +
    + warning + {{ 'calculated-fields.hint.max-args' | translate }} +
    + } +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.scss new file mode 100644 index 0000000000..ae8fd25170 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.scss @@ -0,0 +1,82 @@ +/** + * 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. + */ +:host { + .arguments-table { + min-height: 108px; + + &-with-error { + min-height: 150px; + } + + .mat-mdc-table { + table-layout: fixed; + } + + .key-text { + font-size: 13px; + } + + .copy-argument-name { + visibility: hidden; + transition: visibility 0.1s; + } + + .argument-name-cell:hover { + .copy-argument-name { + visibility: visible; + } + } + } + + .max-args-warning { + .mat-icon { + color: #FAA405; + } + } + + .tb-form-table-row-cell-buttons { + --mat-badge-legacy-small-size-container-size: 8px; + --mat-badge-small-size-container-overlap-offset: -5px; + --mat-badge-small-size-text-size: 0; + } +} + +:host ::ng-deep { + .mat-mdc-standard-chip { + .mdc-evolution-chip__cell--primary, .mdc-evolution-chip__action--primary, .mdc-evolution-chip__text-label { + overflow: hidden; + } + } + + .arguments-table:not(.arguments-table-with-error) { + .mdc-data-table__row:last-child .mat-mdc-cell { + border-bottom: none; + } + } + + .arguments-table { + .mat-mdc-header-row.mat-row-select .mat-mdc-header-cell.entity-type-header { + padding: 0 28px 0 0; + } + } + + .copy-argument-name { + .mat-icon { + font-size: 16px; + padding: 4px; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts new file mode 100644 index 0000000000..945fc67ad4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts @@ -0,0 +1,292 @@ +/// +/// 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 { + AfterViewInit, + ChangeDetectorRef, + Component, + DestroyRef, + forwardRef, + Input, + OnChanges, + Renderer2, + SimpleChanges, + ViewChild, + ViewContainerRef, +} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, +} from '@angular/forms'; +import { + ArgumentEntityType, + ArgumentType, + ArgumentTypeTranslations, + CalculatedFieldArgument, + CalculatedFieldArgumentValue, + CalculatedFieldType, +} from '@shared/models/calculated-field.models'; +import { CalculatedFieldArgumentPanelComponent } from '@home/components/calculated-fields/components/public-api'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { getEntityDetailsPageURL, isDefined, isDefinedAndNotNull, isEqual } from '@core/utils'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { TbTableDatasource } from '@shared/components/table/table-datasource.abstract'; +import { EntityService } from '@core/http/entity.service'; +import { MatSort } from '@angular/material/sort'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { catchError } from 'rxjs/operators'; +import { NEVER } from 'rxjs'; + +@Component({ + selector: 'tb-calculated-field-arguments-table', + templateUrl: './calculated-field-arguments-table.component.html', + styleUrls: [`calculated-field-arguments-table.component.scss`], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CalculatedFieldArgumentsTableComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CalculatedFieldArgumentsTableComponent), + multi: true + } + ], +}) +export class CalculatedFieldArgumentsTableComponent implements ControlValueAccessor, Validator, OnChanges, AfterViewInit { + + @Input() entityId: EntityId; + @Input() tenantId: string; + @Input() entityName: string; + @Input() calculatedFieldType: CalculatedFieldType; + + @ViewChild(MatSort, { static: true }) sort: MatSort; + + errorText = ''; + argumentsFormArray = this.fb.array([]); + entityNameMap = new Map(); + entityNameErrorSet = new Set(); + sortOrder = { direction: 'asc', property: '' }; + dataSource = new CalculatedFieldArgumentDatasource(); + + readonly entityTypeTranslations = entityTypeTranslations; + readonly ArgumentTypeTranslations = ArgumentTypeTranslations; + readonly ArgumentEntityType = ArgumentEntityType; + readonly ArgumentType = ArgumentType; + readonly CalculatedFieldType = CalculatedFieldType; + readonly maxArgumentsPerCF = getCurrentAuthState(this.store).maxArgumentsPerCF; + + private popoverComponent: TbPopoverComponent; + private propagateChange: (argumentsObj: Record) => void = () => {}; + + constructor( + private fb: FormBuilder, + private popoverService: TbPopoverService, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef, + private renderer: Renderer2, + private entityService: EntityService, + private destroyRef: DestroyRef, + private store: Store + ) { + this.argumentsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => { + this.updateEntityNameMap(value); + this.updateDataSource(value); + this.propagateChange(this.getArgumentsObject(value)); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.calculatedFieldType?.previousValue + && changes.calculatedFieldType.currentValue !== changes.calculatedFieldType.previousValue) { + this.argumentsFormArray.updateValueAndValidity(); + } + } + + ngAfterViewInit(): void { + this.sort.sortChange.asObservable().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { + this.sortOrder.property = this.sort.active; + this.sortOrder.direction = this.sort.direction; + this.updateDataSource(this.argumentsFormArray.value); + }); + } + + registerOnChange(fn: (argumentsObj: Record) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_): void {} + + validate(): ValidationErrors | null { + this.updateErrorText(); + return this.errorText ? { argumentsFormArray: false } : null; + } + + onDelete($event: Event, argument: CalculatedFieldArgumentValue): void { + $event.stopPropagation(); + const index = this.argumentsFormArray.controls.findIndex(control => isEqual(control.value, argument)); + this.argumentsFormArray.removeAt(index); + this.argumentsFormArray.markAsDirty(); + } + + manageArgument($event: Event, matButton: MatButton, argument = {} as CalculatedFieldArgumentValue, index?: number): void { + $event?.stopPropagation(); + if (this.popoverComponent && !this.popoverComponent.tbHidden) { + this.popoverComponent.hide(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const ctx = { + index, + argument, + entityId: this.entityId, + calculatedFieldType: this.calculatedFieldType, + buttonTitle: this.argumentsFormArray.at(index)?.value ? 'action.apply' : 'action.add', + tenantId: this.tenantId, + entityName: this.entityName, + entityHasError: this.entityNameErrorSet.has(argument.refEntityId?.id), + usedArgumentNames: this.argumentsFormArray.value.map(({ argumentName }) => argumentName).filter(name => name !== argument.argumentName), + }; + this.popoverComponent = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, CalculatedFieldArgumentPanelComponent, isDefined(index) ? 'left' : 'right', false, null, + ctx, + {}, + {}, {}, true); + this.popoverComponent.tbComponentRef.instance.argumentsDataApplied.subscribe(({ value, index }) => { + this.popoverComponent.hide(); + const formGroup = this.fb.group(value); + if (isDefinedAndNotNull(index)) { + this.argumentsFormArray.setControl(index, formGroup); + } else { + this.argumentsFormArray.push(formGroup); + } + formGroup.markAsDirty(); + this.cd.markForCheck(); + }); + } + } + + private updateDataSource(value: CalculatedFieldArgumentValue[]): void { + const sortedValue = this.sortData(value); + this.dataSource.loadData(sortedValue); + } + + private updateErrorText(): void { + if (this.calculatedFieldType === CalculatedFieldType.SIMPLE + && this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) { + this.errorText = 'calculated-fields.hint.arguments-simple-with-rolling'; + } else if (this.entityNameErrorSet.size) { + this.errorText = 'calculated-fields.hint.arguments-entity-not-found'; + } else if (!this.argumentsFormArray.controls.length) { + this.errorText = 'calculated-fields.hint.arguments-empty'; + } else { + this.errorText = ''; + } + } + + private getArgumentsObject(value: CalculatedFieldArgumentValue[]): Record { + return value.reduce((acc, argumentValue) => { + const { argumentName, ...argument } = argumentValue as CalculatedFieldArgumentValue; + acc[argumentName] = argument; + return acc; + }, {} as Record); + } + + writeValue(argumentsObj: Record): void { + this.argumentsFormArray.clear(); + this.populateArgumentsFormArray(argumentsObj) + } + + getEntityDetailsPageURL(id: string, type: EntityType): string { + return getEntityDetailsPageURL(id, type); + } + + private populateArgumentsFormArray(argumentsObj: Record): void { + Object.keys(argumentsObj).forEach(key => { + const value: CalculatedFieldArgumentValue = { + ...argumentsObj[key], + argumentName: key + }; + this.argumentsFormArray.push(this.fb.group(value), { emitEvent: false }); + }); + this.argumentsFormArray.updateValueAndValidity(); + } + + private updateEntityNameMap(value: CalculatedFieldArgumentValue[]): void { + this.entityNameErrorSet.clear(); + value.forEach(({ refEntityId = {}}) => { + if (refEntityId.id && !this.entityNameMap.has(refEntityId.id)) { + const { id, entityType } = refEntityId as EntityId; + this.entityService.getEntity(entityType as EntityType, id, { ignoreLoading: true, ignoreErrors: true }) + .pipe( + catchError(() => { + this.entityNameErrorSet.add(id); + return NEVER; + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(entity => this.entityNameMap.set(id, entity.name)); + } + }); + } + + private getSortValue(argument: CalculatedFieldArgumentValue, column: string): string { + switch (column) { + case 'entityType': + if (argument.refEntityId?.entityType === ArgumentEntityType.Tenant) { + return 'calculated-fields.argument-current-tenant'; + } else if (argument.refEntityId?.id) { + return entityTypeTranslations.get((argument.refEntityId)?.entityType as unknown as EntityType).type; + } else { + return 'calculated-fields.argument-current'; + } + case 'type': + return ArgumentTypeTranslations.get(argument.refEntityKey.type); + case 'key': + return argument.refEntityKey.key; + default: + return argument.argumentName; + } + } + + private sortData(data: CalculatedFieldArgumentValue[]): CalculatedFieldArgumentValue[] { + return data.sort((a, b) => { + const valA = this.getSortValue(a, this.sortOrder.property) ?? ''; + const valB = this.getSortValue(b, this.sortOrder.property) ?? ''; + return (this.sortOrder.direction === 'asc' ? 1 : -1) * valA.localeCompare(valB); + }); + } +} + +class CalculatedFieldArgumentDatasource extends TbTableDatasource { + constructor() { + super(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.html new file mode 100644 index 0000000000..d9cb7eb021 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.html @@ -0,0 +1,48 @@ + +
    + +

    {{ 'calculated-fields.debugging' | translate}}

    + + +
    +
    + +
    +
    + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.scss new file mode 100644 index 0000000000..c4798d06f9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.scss @@ -0,0 +1,26 @@ +/** + * 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. + */ +:host { + .debug-dialog-container { + width: 1080px; + max-width: 100%; + + .debug-dialog-content { + height: 65vh; + border-radius: 0; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.ts new file mode 100644 index 0000000000..0fcac393f0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.ts @@ -0,0 +1,70 @@ +/// +/// 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 { AfterViewInit, Component, Inject, ViewChild } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { CalculatedFieldEventBody, DebugEventType, Event, EventType } from '@shared/models/event.models'; +import { EventTableComponent } from '@home/components/event/event-table.component'; +import { + CalculatedField, + CalculatedFieldTestScriptFn, + CalculatedFieldType +} from '@shared/models/calculated-field.models'; + +export interface CalculatedFieldDebugDialogData { + tenantId: string; + value: CalculatedField; + getTestScriptDialogFn: CalculatedFieldTestScriptFn; +} + +@Component({ + selector: 'tb-calculated-field-debug-dialog', + styleUrls: ['calculated-field-debug-dialog.component.scss'], + templateUrl: './calculated-field-debug-dialog.component.html', +}) +export class CalculatedFieldDebugDialogComponent extends DialogComponent implements AfterViewInit { + + @ViewChild(EventTableComponent, {static: true}) eventsTable: EventTableComponent; + + readonly DebugEventType = DebugEventType; + readonly debugEventTypes = DebugEventType; + readonly EventType = EventType; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: CalculatedFieldDebugDialogData, + protected dialogRef: MatDialogRef) { + super(store, router, dialogRef); + } + + ngAfterViewInit(): void { + this.eventsTable.entitiesTable.updateData(); + this.eventsTable.entitiesTable.cellActionDescriptors[0].isEnabled = (event => this.data.value.type === CalculatedFieldType.SCRIPT && !!(event as Event).body.arguments) + } + + cancel(): void { + this.dialogRef.close(null); + } + + onDebugEventSelected(event: CalculatedFieldEventBody): void { + this.data.getTestScriptDialogFn(this.data.value, JSON.parse(event.arguments)) + .subscribe(expression => this.dialogRef.close(expression)); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html new file mode 100644 index 0000000000..813f6b1b4c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html @@ -0,0 +1,203 @@ + +
    + +

    {{ 'entity.type-calculated-field' | translate}}

    + +
    + +
    +
    +
    +
    +
    {{ 'common.general' | translate }}
    +
    + + {{ 'entity-field.title' | translate }} + + @if (fieldFormGroup.get('name').errors && fieldFormGroup.get('name').touched) { + + @if (fieldFormGroup.get('name').hasError('required')) { + {{ 'common.hint.title-required' | translate }} + } @else if (fieldFormGroup.get('name').hasError('pattern')) { + {{ 'common.hint.title-pattern' | translate }} + } @else if (fieldFormGroup.get('name').hasError('maxlength')) { + {{ 'common.hint.title-max-length' | translate }} + } + + } + + +
    + + {{ 'common.type' | translate }} + + @for (type of fieldTypes; track type) { + {{ CalculatedFieldTypeTranslations.get(type) | translate}} + } + + +
    + +
    +
    {{ 'calculated-fields.arguments' | translate }}
    + +
    +
    +
    {{ 'calculated-fields.expression' | translate }}
    + + + @if (configFormGroup.get('expressionSIMPLE').errors && configFormGroup.get('expressionSIMPLE').touched) { + + @if (configFormGroup.get('expressionSIMPLE').hasError('required')) { + {{ 'calculated-fields.hint.expression-required' | translate }} + } @else if (configFormGroup.get('expressionSIMPLE').hasError('pattern')) { + {{ 'calculated-fields.hint.expression-invalid' | translate }} + } @else if (configFormGroup.get('expressionSIMPLE').hasError('maxLength')) { + {{ 'calculated-fields.hint.expression-max-length' | translate }} + } + + } @else { + {{ 'calculated-fields.hint.expression' | translate }} + } + +
    + +
    {{ 'api-usage.tbel' | translate }}
    + +
    +
    + +
    +
    +
    +
    +
    {{ 'calculated-fields.output' | translate }}
    +
    + + {{ 'calculated-fields.output-type' | translate }} + + @for (type of outputTypes; track type) { + {{ OutputTypeTranslations.get(type) | translate}} + } + + + @if (outputFormGroup.get('type').value === OutputType.Attribute + && (data.entityId.entityType === EntityType.DEVICE || data.entityId.entityType === EntityType.DEVICE_PROFILE)) { + + {{ 'calculated-fields.attribute-scope' | translate }} + + + {{ 'calculated-fields.server-attributes' | translate }} + + + {{ 'calculated-fields.shared-attributes' | translate }} + + + + } +
    + @if (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) { +
    + + + {{ (outputFormGroup.get('type').value === OutputType.Timeseries + ? 'calculated-fields.timeseries-key' + : 'calculated-fields.attribute-key') + | translate }} + + + @if (outputFormGroup.get('name').errors && outputFormGroup.get('name').touched) { + + @if (outputFormGroup.get('name').hasError('required')) { + {{ 'common.hint.key-required' | translate }} + } @else if (outputFormGroup.get('name').hasError('pattern')) { + {{ 'common.hint.key-pattern' | translate }} + } @else if (outputFormGroup.get('name').hasError('maxlength')) { + {{ 'common.hint.key-max-length' | translate }} + } + + } + + + {{ 'calculated-fields.decimals-by-default' | translate }} + + @if (outputFormGroup.get('decimalsByDefault').errors && outputFormGroup.get('decimalsByDefault').touched) { + {{ 'calculated-fields.hint.decimals-range' | translate }} + } + +
    + } +
    +
    +
    +
    +
    + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss new file mode 100644 index 0000000000..8bc422eed1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss @@ -0,0 +1,56 @@ +/** + * 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. + */ + +.calculated-field-dialog-container { + width: 869px; + max-width: 100%; +} + +.tbel-script-lang-chip { + line-height: 20px; + font-size: 14px; + font-weight: 500; + color: white; + border-radius: 100px; + width: 70px; + min-width: 70px; + display: flex; + justify-content: center; + margin-top: 2px; + margin-right: 4px; +} + +.tb-js-func { + .ace_tb { + &.ace_calculated-field { + &-ctx { + color: #C52F00; + } + &-args { + color: #185F2A; + } + &-key { + color: #c24c1a; + } + &-time-window, &-values, &-func, &-value, &-ts { + color: #7214D0; + } + &-start-ts, &-end-ts { + color: #2CAA00; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts new file mode 100644 index 0000000000..169d6dff50 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts @@ -0,0 +1,237 @@ +/// +/// 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, Inject, ViewEncapsulation } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { + CalculatedField, + CalculatedFieldConfiguration, + calculatedFieldDefaultScript, + CalculatedFieldTestScriptFn, + CalculatedFieldType, + CalculatedFieldTypeTranslations, + getCalculatedFieldArgumentsEditorCompleter, + getCalculatedFieldArgumentsHighlights, + OutputType, + OutputTypeTranslations +} from '@shared/models/calculated-field.models'; +import { digitsRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { map, startWith, switchMap } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ScriptLanguage } from '@shared/models/rule-node.models'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { Observable } from 'rxjs'; +import { EntityId } from '@shared/models/id/entity-id'; +import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model'; + +export interface CalculatedFieldDialogData { + value?: CalculatedField; + buttonTitle: string; + entityId: EntityId; + debugLimitsConfiguration: string; + tenantId: string; + entityName?: string; + additionalDebugActionConfig: AdditionalDebugActionConfig<(calculatedField: CalculatedField) => void>; + getTestScriptDialogFn: CalculatedFieldTestScriptFn; + isDirty?: boolean; +} + +@Component({ + selector: 'tb-calculated-field-dialog', + templateUrl: './calculated-field-dialog.component.html', + styleUrls: ['./calculated-field-dialog.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class CalculatedFieldDialogComponent extends DialogComponent { + + fieldFormGroup = this.fb.group({ + name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], + type: [CalculatedFieldType.SIMPLE], + debugSettings: [], + configuration: this.fb.group({ + arguments: this.fb.control({}), + expressionSIMPLE: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], + expressionSCRIPT: [calculatedFieldDefaultScript], + output: this.fb.group({ + name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], + scope: [{ value: AttributeScope.SERVER_SCOPE, disabled: true }], + type: [OutputType.Timeseries], + decimalsByDefault: [null as number, [Validators.min(0), Validators.max(15), Validators.pattern(digitsRegex)]], + }), + }), + }); + + functionArgs$ = this.configFormGroup.get('arguments').valueChanges + .pipe( + startWith(this.data.value?.configuration?.arguments ?? {}), + map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)]) + ); + + argumentsEditorCompleter$ = this.configFormGroup.get('arguments').valueChanges + .pipe( + startWith(this.data.value?.configuration?.arguments ?? {}), + map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj)) + ); + + argumentsHighlightRules$ = this.configFormGroup.get('arguments').valueChanges + .pipe( + startWith(this.data.value?.configuration?.arguments ?? {}), + map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj)) + ); + + additionalDebugActionConfig = this.data.value?.id ? { + ...this.data.additionalDebugActionConfig, + action: () => this.data.additionalDebugActionConfig.action({ id: this.data.value.id, ...this.fromGroupValue }), + } : null; + + readonly OutputTypeTranslations = OutputTypeTranslations; + readonly OutputType = OutputType; + readonly AttributeScope = AttributeScope; + readonly EntityType = EntityType; + readonly CalculatedFieldType = CalculatedFieldType; + readonly ScriptLanguage = ScriptLanguage; + readonly fieldTypes = Object.values(CalculatedFieldType) as CalculatedFieldType[]; + readonly outputTypes = Object.values(OutputType) as OutputType[]; + readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: CalculatedFieldDialogData, + protected dialogRef: MatDialogRef, + private calculatedFieldsService: CalculatedFieldsService, + private destroyRef: DestroyRef, + private fb: FormBuilder) { + super(store, router, dialogRef); + this.observeIsLoading(); + this.applyDialogData(); + this.observeTypeChanges(); + } + + get configFormGroup(): FormGroup { + return this.fieldFormGroup.get('configuration') as FormGroup; + } + + get outputFormGroup(): FormGroup { + return this.fieldFormGroup.get('configuration').get('output') as FormGroup; + } + + get fromGroupValue(): CalculatedField { + const { configuration, type, name, ...rest } = this.fieldFormGroup.value; + const { expressionSIMPLE, expressionSCRIPT, output, ...restConfig } = configuration; + return { + configuration: { + ...restConfig, + type, expression: configuration['expression'+type].trim(), + output: { ...output, name: output.name?.trim() ?? '' } + }, + name: name.trim(), + type, + ...rest, + } as CalculatedField; + } + + cancel(): void { + this.dialogRef.close(null); + } + + add(): void { + if (this.fieldFormGroup.valid) { + this.calculatedFieldsService.saveCalculatedField({ entityId: this.data.entityId, ...(this.data.value ?? {}), ...this.fromGroupValue}) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(calculatedField => this.dialogRef.close(calculatedField)); + } + } + + onTestScript(): void { + const calculatedFieldId = this.data.value?.id?.id; + let testScriptDialogResult$: Observable; + + if (calculatedFieldId) { + testScriptDialogResult$ = this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId) + .pipe( + switchMap(event => { + const args = event?.arguments ? JSON.parse(event.arguments) : null; + return this.data.getTestScriptDialogFn(this.fromGroupValue, args, false); + }), + takeUntilDestroyed(this.destroyRef) + ) + } else { + testScriptDialogResult$ = this.data.getTestScriptDialogFn(this.fromGroupValue, null, false); + } + + testScriptDialogResult$.subscribe(expression => { + this.configFormGroup.get('expressionSCRIPT').setValue(expression); + this.configFormGroup.get('expressionSCRIPT').markAsDirty(); + }); + } + + private applyDialogData(): void { + const { configuration = {}, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, ...value } = this.data.value ?? {}; + const { expression, ...restConfig } = configuration as CalculatedFieldConfiguration; + const updatedConfig = { ...restConfig , ['expression'+type]: expression }; + this.fieldFormGroup.patchValue({ configuration: updatedConfig, type, debugSettings, ...value }, {emitEvent: false}); + } + + private observeTypeChanges(): void { + this.toggleKeyByCalculatedFieldType(this.fieldFormGroup.get('type').value); + this.toggleScopeByOutputType(this.outputFormGroup.get('type').value); + + this.outputFormGroup.get('type').valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(type => this.toggleScopeByOutputType(type)); + this.fieldFormGroup.get('type').valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(type => this.toggleKeyByCalculatedFieldType(type)); + } + + private toggleScopeByOutputType(type: OutputType): void { + this.outputFormGroup.get('scope')[type === OutputType.Attribute? 'enable' : 'disable']({emitEvent: false}); + } + + private toggleKeyByCalculatedFieldType(type: CalculatedFieldType): void { + if (type === CalculatedFieldType.SIMPLE) { + this.outputFormGroup.get('name').enable({emitEvent: false}); + this.configFormGroup.get('expressionSIMPLE').enable({emitEvent: false}); + this.configFormGroup.get('expressionSCRIPT').disable({emitEvent: false}); + } else { + this.outputFormGroup.get('name').disable({emitEvent: false}); + this.configFormGroup.get('expressionSIMPLE').disable({emitEvent: false}); + this.configFormGroup.get('expressionSCRIPT').enable({emitEvent: false}); + } + } + + private observeIsLoading(): void { + this.isLoading$.pipe(takeUntilDestroyed()).subscribe(loading => { + if (loading) { + this.fieldFormGroup.disable({emitEvent: false}); + } else { + this.fieldFormGroup.enable({emitEvent: false}); + this.toggleScopeByOutputType(this.outputFormGroup.get('type').value); + this.toggleKeyByCalculatedFieldType(this.fieldFormGroup.get('type').value); + if (this.data.isDirty) { + this.fieldFormGroup.markAsDirty(); + } + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html new file mode 100644 index 0000000000..15286af377 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html @@ -0,0 +1,208 @@ + +
    +
    +
    {{ 'calculated-fields.argument-settings' | translate }}
    +
    +
    +
    {{ 'calculated-fields.argument-name' | translate }}
    + + + @if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('required')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('duplicateName')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('pattern')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('maxlength')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('equalCtx')) { + + warning + + } + +
    + +
    +
    {{ 'entity.entity-type' | translate }}
    + + + @for (type of argumentEntityTypes; track type) { + {{ ArgumentEntityTypeTranslations.get(type) | translate }} + } + + +
    + @if (ArgumentEntityTypeParamsMap.has(entityType)) { +
    +
    {{ ArgumentEntityTypeParamsMap.get(entityType).title | translate }}
    + +
    + } +
    + +
    +
    {{ 'calculated-fields.argument-type' | translate }}
    + + + @for (type of argumentTypes; track type) { + {{ ArgumentTypeTranslations.get(type) | translate }} + } + + @if (refEntityKeyFormGroup.get('type').hasError('required') && refEntityKeyFormGroup.get('type').touched) { + + warning + + } + +
    + @if (entityFilter.singleEntity?.id || entityType === ArgumentEntityType.Current || entityType === ArgumentEntityType.Tenant) { + @if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Attribute) { +
    +
    {{ 'calculated-fields.timeseries-key' | translate }}
    + @if (refEntityKeyFormGroup.get('type').value === ArgumentType.LatestTelemetry) { + + } @else { + + } + + + +
    + } @else { + @if (enableAttributeScopeSelection) { +
    +
    {{ 'calculated-fields.attribute-scope' | translate }}
    + + + + {{ 'calculated-fields.server-attributes' | translate }} + + + {{ 'calculated-fields.client-attributes' | translate }} + + + {{ 'calculated-fields.shared-attributes' | translate }} + + + +
    + } +
    +
    {{ 'calculated-fields.attribute-key' | translate }}
    + +
    + } + } +
    + @if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Rolling) { +
    +
    {{ 'calculated-fields.default-value' | translate }}
    + + + +
    + } @else { +
    +
    {{ 'calculated-fields.time-window' | translate }}
    + +
    + @if (maxDataPointsPerRollingArg) { +
    +
    {{ 'calculated-fields.limit' | translate }}
    +
    + + + + + + +
    +
    + } + } +
    +
    +
    + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.scss new file mode 100644 index 0000000000..773489ee60 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.scss @@ -0,0 +1,51 @@ +/** + * 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. + */ +@use '../../../../../../../scss/constants' as constants; + +$panel-width: 520px; + +:host { + display: flex; + width: $panel-width; + max-width: 100%; + max-height: 100vh; + + .fixed-title-width { + @media #{constants.$mat-xs} { + min-width: 120px; + } + } + + .limit-field-row { + @media screen and (max-width: $panel-width) { + display: flex; + flex-direction: column; + + .fixed-title-width { + align-self: flex-start; + padding-top: 8px; + } + } + } +} + +:host ::ng-deep { + .time-interval-field { + .advanced-input { + flex-direction: column; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts new file mode 100644 index 0000000000..8aa61eb1a4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts @@ -0,0 +1,267 @@ +/// +/// 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 { AfterViewInit, ChangeDetectorRef, Component, Input, OnInit, output, ViewChild } from '@angular/core'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; +import { charsWithNumRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; +import { + ArgumentEntityType, + ArgumentEntityTypeParamsMap, + ArgumentEntityTypeTranslations, + ArgumentType, + ArgumentTypeTranslations, + CalculatedFieldArgumentValue, + CalculatedFieldType, + getCalculatedFieldCurrentEntityFilter +} from '@shared/models/calculated-field.models'; +import { debounceTime, delay, distinctUntilChanged, filter } from 'rxjs/operators'; +import { EntityType } from '@shared/models/entity-type.models'; +import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { DatasourceType } from '@shared/models/widget.models'; +import { EntityId } from '@shared/models/id/entity-id'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EntityFilter } from '@shared/models/query/query.models'; +import { AliasFilterType } from '@shared/models/alias.models'; +import { merge } from 'rxjs'; +import { MINUTE } from '@shared/models/time/time.models'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { AppState } from '@core/core.state'; +import { Store } from '@ngrx/store'; +import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component'; + +@Component({ + selector: 'tb-calculated-field-argument-panel', + templateUrl: './calculated-field-argument-panel.component.html', + styleUrls: ['./calculated-field-argument-panel.component.scss'] +}) +export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewInit { + + @Input() buttonTitle: string; + @Input() index: number; + @Input() argument: CalculatedFieldArgumentValue; + @Input() entityId: EntityId; + @Input() tenantId: string; + @Input() entityName: string; + @Input() entityHasError: boolean; + @Input() calculatedFieldType: CalculatedFieldType; + @Input() usedArgumentNames: string[]; + + @ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent; + + argumentsDataApplied = output<{ value: CalculatedFieldArgumentValue, index: number }>(); + + readonly maxDataPointsPerRollingArg = getCurrentAuthState(this.store).maxDataPointsPerRollingArg; + readonly defaultLimit = Math.floor(this.maxDataPointsPerRollingArg / 10); + + argumentFormGroup = this.fb.group({ + argumentName: ['', [Validators.required, this.uniqNameRequired(), this.notEqualCtxValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], + refEntityId: this.fb.group({ + entityType: [ArgumentEntityType.Current], + id: [''] + }), + refEntityKey: this.fb.group({ + type: [ArgumentType.LatestTelemetry, [Validators.required]], + key: ['', [Validators.pattern(oneSpaceInsideRegex)]], + scope: [{ value: AttributeScope.SERVER_SCOPE, disabled: true }, [Validators.required]], + }), + defaultValue: ['', [Validators.pattern(oneSpaceInsideRegex)]], + limit: [{ value: this.defaultLimit, disabled: !this.maxDataPointsPerRollingArg }, [Validators.required, Validators.min(1), Validators.max(this.maxDataPointsPerRollingArg)]], + timeWindow: [MINUTE * 15, [Validators.required]], + }); + + argumentTypes: ArgumentType[]; + entityFilter: EntityFilter; + + readonly argumentEntityTypes = Object.values(ArgumentEntityType) as ArgumentEntityType[]; + readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations; + readonly ArgumentType = ArgumentType; + readonly DataKeyType = DataKeyType; + readonly EntityType = EntityType; + readonly datasourceType = DatasourceType; + readonly ArgumentTypeTranslations = ArgumentTypeTranslations; + readonly AttributeScope = AttributeScope; + readonly ArgumentEntityType = ArgumentEntityType; + readonly ArgumentEntityTypeParamsMap = ArgumentEntityTypeParamsMap; + + private currentEntityFilter: EntityFilter; + + constructor( + private fb: FormBuilder, + private cd: ChangeDetectorRef, + private popover: TbPopoverComponent, + private store: Store + ) { + this.observeEntityFilterChanges(); + this.observeEntityTypeChanges() + this.observeEntityKeyChanges(); + this.observeUpdatePosition(); + } + + get entityType(): ArgumentEntityType { + return this.argumentFormGroup.get('refEntityId').get('entityType').value; + } + + get refEntityIdFormGroup(): FormGroup { + return this.argumentFormGroup.get('refEntityId') as FormGroup; + } + + get refEntityKeyFormGroup(): FormGroup { + return this.argumentFormGroup.get('refEntityKey') as FormGroup; + } + + get enableAttributeScopeSelection(): boolean { + return this.entityType === ArgumentEntityType.Device + || (this.entityType === ArgumentEntityType.Current + && (this.entityId.entityType === EntityType.DEVICE || this.entityId.entityType === EntityType.DEVICE_PROFILE)) + } + + ngOnInit(): void { + this.argumentFormGroup.patchValue(this.argument, {emitEvent: false}); + this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId); + this.updateEntityFilter(this.argument.refEntityId?.entityType, true); + this.toggleByEntityKeyType(this.argument.refEntityKey?.type); + this.setInitialEntityKeyType(); + + this.argumentTypes = Object.values(ArgumentType) + .filter(type => type !== ArgumentType.Rolling || this.calculatedFieldType === CalculatedFieldType.SCRIPT); + } + + ngAfterViewInit(): void { + if (this.entityHasError) { + this.entityAutocomplete.selectEntityFormGroup.get('entity').markAsTouched(); + } + } + + saveArgument(): void { + const { refEntityId, ...restConfig } = this.argumentFormGroup.value; + const value = (refEntityId.entityType === ArgumentEntityType.Current ? restConfig : { refEntityId, ...restConfig }) as CalculatedFieldArgumentValue; + if (refEntityId.entityType === ArgumentEntityType.Tenant) { + refEntityId.id = this.tenantId; + } + if (value.defaultValue) { + value.defaultValue = value.defaultValue.trim(); + } + value.refEntityKey.key = value.refEntityKey.key.trim(); + this.argumentsDataApplied.emit({ value, index: this.index }); + } + + cancel(): void { + this.popover.hide(); + } + + private toggleByEntityKeyType(type: ArgumentType): void { + const isAttribute = type === ArgumentType.Attribute; + const isRolling = type === ArgumentType.Rolling; + this.argumentFormGroup.get('refEntityKey').get('scope')[isAttribute? 'enable' : 'disable']({ emitEvent: false }); + this.argumentFormGroup.get('limit')[isRolling? 'enable' : 'disable']({ emitEvent: false }); + this.argumentFormGroup.get('timeWindow')[isRolling? 'enable' : 'disable']({ emitEvent: false }); + this.argumentFormGroup.get('defaultValue')[isRolling? 'disable' : 'enable']({ emitEvent: false }); + } + + private updateEntityFilter(entityType: ArgumentEntityType = ArgumentEntityType.Current, onInit = false): void { + let entityFilter: EntityFilter; + switch (entityType) { + case ArgumentEntityType.Current: + entityFilter = this.currentEntityFilter; + break; + case ArgumentEntityType.Tenant: + entityFilter = { + type: AliasFilterType.singleEntity, + singleEntity: { + id: this.tenantId, + entityType: EntityType.TENANT + }, + }; + break; + default: + entityFilter = { + type: AliasFilterType.singleEntity, + singleEntity: this.argumentFormGroup.get('refEntityId').value as unknown as EntityId, + }; + } + if (!onInit) { + this.argumentFormGroup.get('refEntityKey').get('key').setValue(''); + } + this.entityFilter = entityFilter; + this.cd.markForCheck(); + } + + private observeEntityFilterChanges(): void { + merge( + this.refEntityIdFormGroup.get('entityType').valueChanges, + this.refEntityKeyFormGroup.get('type').valueChanges, + this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)), + this.refEntityKeyFormGroup.get('scope').valueChanges, + ) + .pipe(debounceTime(50), takeUntilDestroyed()) + .subscribe(() => this.updateEntityFilter(this.entityType)); + } + + private observeEntityTypeChanges(): void { + this.argumentFormGroup.get('refEntityId').get('entityType').valueChanges + .pipe(distinctUntilChanged(), takeUntilDestroyed()) + .subscribe(type => { + this.argumentFormGroup.get('refEntityId').get('id').setValue(''); + this.argumentFormGroup.get('refEntityId') + .get('id')[type === ArgumentEntityType.Tenant || type === ArgumentEntityType.Current ? 'disable' : 'enable'](); + if (!this.enableAttributeScopeSelection) { + this.refEntityKeyFormGroup.get('scope').setValue(AttributeScope.SERVER_SCOPE); + } + }); + } + + private uniqNameRequired(): ValidatorFn { + return (control: FormControl) => { + const newName = control.value.trim().toLowerCase(); + const isDuplicate = this.usedArgumentNames?.some(name => name.toLowerCase() === newName); + + return isDuplicate ? { duplicateName: true } : null; + }; + } + + private observeEntityKeyChanges(): void { + this.argumentFormGroup.get('refEntityKey').get('type').valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(type => this.toggleByEntityKeyType(type)); + } + + private setInitialEntityKeyType(): void { + if (this.calculatedFieldType === CalculatedFieldType.SIMPLE && this.argument.refEntityKey?.type === ArgumentType.Rolling) { + const typeControl = this.argumentFormGroup.get('refEntityKey').get('type'); + typeControl.setValue(null); + typeControl.markAsTouched(); + } + } + + private notEqualCtxValidator(): ValidatorFn { + return (control: FormControl) => { + const trimmedValue = control.value.trim().toLowerCase(); + return trimmedValue === 'ctx' ? { equalCtx: true } : null; + }; + } + + private observeUpdatePosition(): void { + merge( + this.refEntityIdFormGroup.get('entityType').valueChanges, + this.refEntityKeyFormGroup.get('type').valueChanges, + this.argumentFormGroup.get('timeWindow').valueChanges, + this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)), + ) + .pipe(delay(50), takeUntilDestroyed()) + .subscribe(() => this.popover.updatePosition()); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts new file mode 100644 index 0000000000..9e3c52bc4f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts @@ -0,0 +1,21 @@ +/// +/// 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. +/// + +export * from './dialog/calculated-field-dialog.component'; +export * from './arguments-table/calculated-field-arguments-table.component'; +export * from './panel/calculated-field-argument-panel.component'; +export * from './debug-dialog/calculated-field-debug-dialog.component'; +export * from './test-dialog/calculated-field-script-test-dialog.component'; diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.html new file mode 100644 index 0000000000..f393a9130b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.html @@ -0,0 +1,58 @@ + +
    +
    {{ 'calculated-fields.arguments' | translate }}
    +
    +
    +
    {{ 'common.name' | translate }}
    +
    {{ 'common.type' | translate }}
    +
    {{ 'common.data' | translate }}
    +
    +
    + @for (group of argumentsFormArray.controls; track group) { +
    + + + + + + + {{ ArgumentTypeTranslations.get(argumentsTypeMap.get(group.get('argumentName').value)) | translate }} + + + +
    + @if (argumentsTypeMap.get(group.get('argumentName').value) === ArgumentType.Rolling) { + + + + } @else { + + + + + } + +
    +
    + } +
    +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.scss new file mode 100644 index 0000000000..cbca3002aa --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.scss @@ -0,0 +1,33 @@ +/** + * 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. + */ +@use '../../../../../../../scss/constants' as constants; + +:host { + .tb-form-table { + min-width: 700px; + } +} + +:host::ng-deep { + .tb-form-table-row { + .argument-value { + .tb-value-type.row { + width: 120px; + min-width: 120px; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.ts new file mode 100644 index 0000000000..7c5580f11a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.ts @@ -0,0 +1,146 @@ +/// +/// 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, forwardRef, Input } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + Validator, + ValidationErrors, + FormBuilder, + FormGroup +} from '@angular/forms'; +import { PageComponent } from '@shared/components/page.component'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { entityTypeTranslations } from '@shared/models/entity-type.models'; +import { + ArgumentType, + ArgumentTypeTranslations, + CalculatedFieldArgumentEventValue, + CalculatedFieldRollingTelemetryArgumentValue, + CalculatedFieldSingleArgumentValue, + CalculatedFieldEventArguments, + CalculatedFieldType +} from '@shared/models/calculated-field.models'; +import { + JsonObjectEditDialogComponent, + JsonObjectEditDialogData +} from '@shared/components/dialog/json-object-edit-dialog.component'; +import { filter } from 'rxjs/operators'; +import { MatDialog } from '@angular/material/dialog'; + +@Component({ + selector: 'tb-calculated-field-test-arguments', + templateUrl: './calculated-field-test-arguments.component.html', + styleUrls: ['./calculated-field-test-arguments.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CalculatedFieldTestArgumentsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CalculatedFieldTestArgumentsComponent), + multi: true, + } + ] +}) +export class CalculatedFieldTestArgumentsComponent extends PageComponent implements ControlValueAccessor, Validator { + + @Input() argumentsTypeMap: Map; + + argumentsFormArray = this.fb.array([]); + + readonly entityTypeTranslations = entityTypeTranslations; + readonly ArgumentTypeTranslations = ArgumentTypeTranslations; + readonly ArgumentType = ArgumentType; + readonly CalculatedFieldType = CalculatedFieldType; + + private propagateChange: (value: CalculatedFieldEventArguments) => void = () => {}; + + constructor(private fb: FormBuilder, private dialog: MatDialog) { + super(); + this.argumentsFormArray.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(() => this.propagateChange(this.getValue())); + } + + registerOnChange(propagateChange: (value: CalculatedFieldEventArguments) => void): void { + this.propagateChange = propagateChange; + } + + registerOnTouched(_): void { + } + + writeValue(argumentsObj: CalculatedFieldEventArguments): void { + this.argumentsFormArray.clear(); + Object.keys(argumentsObj).forEach(key => { + const value = { ...argumentsObj[key], argumentName: key } as CalculatedFieldArgumentEventValue; + this.argumentsFormArray.push(this.argumentsTypeMap.get(key) === ArgumentType.Rolling + ? this.getRollingArgumentFormGroup(value as CalculatedFieldRollingTelemetryArgumentValue) + : this.getSimpleArgumentFormGroup(value as CalculatedFieldSingleArgumentValue) + ); + }); + } + + validate(): ValidationErrors | null { + return this.argumentsFormArray.valid ? null : { arguments: { valid: false } }; + } + + openEditJSONDialog(group: FormGroup): void { + this.dialog.open(JsonObjectEditDialogComponent, { + disableClose: true, + height: '760px', + maxHeight: '70vh', + minWidth: 'min(700px, 100%)', + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + jsonValue: this.argumentsTypeMap.get(group.get('argumentName').value) === ArgumentType.Rolling ? group.value.rollingJson : group.value, + required: true, + fillHeight: true + } + }).afterClosed() + .pipe(filter(Boolean)) + .subscribe(result => this.argumentsTypeMap.get(group.get('argumentName').value) === ArgumentType.Rolling + ? group.get('rollingJson').patchValue({ values: (result as CalculatedFieldRollingTelemetryArgumentValue).values, timeWindow: (result as CalculatedFieldRollingTelemetryArgumentValue).timeWindow }) + : group.patchValue({ ts: (result as CalculatedFieldSingleArgumentValue).ts, value: (result as CalculatedFieldSingleArgumentValue).value }) ); + } + + private getSimpleArgumentFormGroup({ argumentName, ts, value }: CalculatedFieldSingleArgumentValue): FormGroup { + return this.fb.group({ + argumentName: [{ value: argumentName, disabled: true}], + ts: [ts], + value: [value] + }) as FormGroup; + } + + private getRollingArgumentFormGroup({ argumentName, timeWindow, values }: CalculatedFieldRollingTelemetryArgumentValue): FormGroup { + return this.fb.group({ + argumentName: [{ value: argumentName, disabled: true }], + rollingJson: [{ values: values ?? [], timeWindow: timeWindow ?? {} }] + }) as FormGroup; + } + + private getValue(): CalculatedFieldEventArguments { + return this.argumentsFormArray.getRawValue().reduce((acc, rowItem) => { + const { argumentName, rollingJson = {}, ...value } = rowItem; + acc[argumentName] = { ...rollingJson, ...value }; + return acc; + }, {}); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.html new file mode 100644 index 0000000000..942d9484b5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.html @@ -0,0 +1,104 @@ + +
    + +

    {{ 'calculated-fields.test-script-function' | translate }} ({{ 'api-usage.tbel' | translate }})

    + +
    +
    +
    +
    +
    +
    +
    + {{ 'calculated-fields.expression' | translate }} +
    + +
    +
    +
    +
    +
    +
    + {{ 'calculated-fields.arguments' | translate }} +
    + +
    +
    +
    +
    +
    + common.output +
    + +
    +
    +
    +
    +
    +
    +
    + + + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.scss new file mode 100644 index 0000000000..94766e944a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.scss @@ -0,0 +1,91 @@ +/** + * 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. + */ + +.cf-test-dialog-container { + .gutter { + background-color: #eee; + background-repeat: no-repeat; + background-position: 50%; + } + + .gutter.gutter-horizontal { + cursor: col-resize; + background-image: url("../../../../../../../assets/split.js/grips/horizontal.png"); + } + + .gutter.gutter-vertical { + cursor: row-resize; + background-image: url("../../../../../../../assets/split.js/grips/vertical.png"); + } + + .block-label { + padding: 4px; + color: #00acc1; + background: rgba(220, 220, 220, .35); + border-radius: 5px; + } + + .test-block-content { + padding-top: 5px; + padding-left: 5px; + border: 1px solid #c0c0c0; + overflow: scroll; + } + + .block-label-container { + position: absolute; + z-index: 10; + font-size: 12px; + font-weight: bold; + + &.left { + right: 112px; + top: 9px; + } + + &.right-bottom { + right: 40px; + top: 6px; + } + + &.right-top { + right: 8px; + top: 2px; + } + } +} + +.tb-js-func { + .ace_tb { + &.ace_calculated-field { + &-ctx { + color: #C52F00; + } + &-args { + color: #185F2A; + } + &-key { + color: #c24c1a; + } + &-time-window, &-values, &-func, &-value, &-ts { + color: #7214D0; + } + &-start-ts, &-end-ts { + color: #2CAA00; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.ts new file mode 100644 index 0000000000..56256511c9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.ts @@ -0,0 +1,238 @@ +/// +/// 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 { + AfterViewInit, + Component, + DestroyRef, + ElementRef, + Inject, + OnDestroy, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, Validators } from '@angular/forms'; +import { NEVER, Observable, of, switchMap } from 'rxjs'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { ContentType } from '@shared/models/constants'; +import { JsonContentComponent } from '@shared/components/json-content.component'; +import { ScriptLanguage } from '@shared/models/rule-node.models'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { beautifyJs } from '@shared/models/beautify.models'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { filter } from 'rxjs/operators'; +import { + ArgumentType, + CalculatedFieldEventArguments, + CalculatedFieldTestScriptInputParams, + TestArgumentTypeMap +} from '@shared/models/calculated-field.models'; +import { TbEditorCompleter } from '@shared/models/ace/completion.models'; +import { AceHighlightRules } from '@shared/models/ace/ace.models'; + +export interface CalculatedFieldTestScriptDialogData extends CalculatedFieldTestScriptInputParams { + argumentsEditorCompleter: TbEditorCompleter; + argumentsHighlightRules: AceHighlightRules; + openCalculatedFieldEdit?: boolean; +} + +@Component({ + selector: 'tb-calculated-field-script-test-dialog', + templateUrl: './calculated-field-script-test-dialog.component.html', + styleUrls: ['./calculated-field-script-test-dialog.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class CalculatedFieldScriptTestDialogComponent extends DialogComponent implements AfterViewInit, OnDestroy { + + @ViewChild('leftPanel', {static: true}) leftPanelElmRef: ElementRef; + @ViewChild('rightPanel', {static: true}) rightPanelElmRef: ElementRef; + @ViewChild('topRightPanel', {static: true}) topRightPanelElmRef: ElementRef; + @ViewChild('bottomRightPanel', {static: true}) bottomRightPanelElmRef: ElementRef; + @ViewChild('testScriptContainer', {static: true}) testScriptContainer: ElementRef; + + @ViewChild('expressionContent', {static: true}) expressionContent: JsonContentComponent; + + calculatedFieldScriptTestFormGroup = this.fb.group({ + expression: ['', Validators.required], + arguments: [], + output: [] + }); + argumentsTypeMap = new Map(); + + readonly ContentType = ContentType; + readonly ScriptLanguage = ScriptLanguage; + readonly functionArgs = ['ctx', ...Object.keys(this.data.arguments)]; + + private testScriptResize: ResizeObserver; + private splitObjects: SplitObject[] = []; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: CalculatedFieldTestScriptDialogData, + protected dialogRef: MatDialogRef, + private dialog: MatDialog, + private fb: FormBuilder, + private destroyRef: DestroyRef, + private calculatedFieldService: CalculatedFieldsService) { + super(store, router, dialogRef); + beautifyJs(this.data.expression, {indent_size: 4}).pipe(filter(Boolean), takeUntilDestroyed()).subscribe( + (res) => this.calculatedFieldScriptTestFormGroup.get('expression').patchValue(res, {emitEvent: false}) + ); + this.calculatedFieldScriptTestFormGroup.get('arguments').patchValue(this.getArgumentsValue()); + } + + ngAfterViewInit(): void { + this.observeResize(); + } + + ngOnDestroy(): void { + super.ngOnDestroy(); + this.testScriptResize.disconnect(); + } + + cancel(): void { + this.dialogRef.close(null); + } + + onTestScript(): void { + this.testScript() + .pipe( + switchMap(output => beautifyJs(output, {indent_size: 4})), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(output => this.calculatedFieldScriptTestFormGroup.get('output').setValue(output)); + } + + save(): void { + this.testScript(true).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { + this.calculatedFieldScriptTestFormGroup.get('expression').markAsPristine(); + this.dialogRef.close(this.calculatedFieldScriptTestFormGroup.get('expression').value); + }); + } + + private testScript(onSave = false): Observable { + if (this.checkInputParamErrors()) { + return this.calculatedFieldService.testScript({ + expression: this.calculatedFieldScriptTestFormGroup.get('expression').value, + arguments: this.getTestArguments() + }).pipe( + switchMap(result => { + if (result.error) { + this.store.dispatch(new ActionNotificationShow( + { + message: result.error, + type: 'error' + })); + return NEVER; + } else { + if (onSave && this.data.openCalculatedFieldEdit) { + this.dialog.closeAll(); + } + return of(result.output); + } + }), + ); + } else { + return NEVER; + } + } + + private checkInputParamErrors(): boolean { + this.expressionContent.validateOnSubmit(); + return !this.calculatedFieldScriptTestFormGroup.get('expression').invalid; + } + + private observeResize(): void { + this.testScriptResize = new ResizeObserver(() => { + this.updateSizes(); + }); + + this.testScriptResize.observe(this.testScriptContainer.nativeElement); + } + + private updateSizes(): void { + this.initSplitLayout(this.testScriptContainer.nativeElement.clientWidth <= 960); + } + + private getTestArguments(): CalculatedFieldEventArguments { + const argumentsValue = this.calculatedFieldScriptTestFormGroup.get('arguments').value; + return Object.keys(argumentsValue) + .reduce((acc, key) => { + acc[key] = argumentsValue[key]; + acc[key].type = TestArgumentTypeMap.get(this.argumentsTypeMap.get(key)); + return acc; + }, {}); + } + + private getArgumentsValue(): CalculatedFieldEventArguments { + return Object.keys(this.data.arguments) + .reduce((acc, key) => { + const { type, ...argumentObj } = this.data.arguments[key]; + this.argumentsTypeMap.set(key, type); + acc[key] = argumentObj; + return acc; + }, {}); + } + + private initSplitLayout(smallMode = false): void { + const [leftPanel, rightPanel, topRightPanel, bottomRightPanel] = [ + this.leftPanelElmRef.nativeElement, + this.rightPanelElmRef.nativeElement, + this.topRightPanelElmRef.nativeElement, + this.bottomRightPanelElmRef.nativeElement + ] as unknown as string[]; + + this.splitObjects.forEach(obj => obj.destroy()); + this.splitObjects = []; + + if (smallMode) { + this.splitObjects.push( + Split([leftPanel, rightPanel], { + sizes: [33, 67], + gutterSize: 8, + cursor: 'row-resize', + direction: 'vertical' + }), + Split([topRightPanel, bottomRightPanel], { + sizes: [50, 50], + gutterSize: 8, + cursor: 'row-resize', + direction: 'vertical' + }), + ); + } else { + this.splitObjects.push( + Split([leftPanel, rightPanel], { + sizes: [50, 50], + gutterSize: 8, + cursor: 'col-resize' + }), + Split([topRightPanel, bottomRightPanel], { + sizes: [50, 50], + gutterSize: 8, + cursor: 'row-resize', + direction: 'vertical' + }) + ); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts index 0be8cf1f25..802a468f58 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts @@ -29,7 +29,7 @@ import { WidgetConfigComponentData, WidgetInfo } from '@home/models/widget-compo import { isDefined, isDefinedAndNotNull } from '@core/utils'; import { TranslateService } from '@ngx-translate/core'; import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; -import { DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeySettingsFunction } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; export interface AddWidgetDialogData { dashboard: Dashboard; diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.ts index 3156109ea1..4a51ee7a19 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.ts @@ -27,7 +27,7 @@ import { WidgetConfigComponentData } from '../../models/widget-component.models' import { isDefined, isDefinedAndNotNull } from '@core/utils'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; -import { DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeySettingsFunction } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts index b732b7ff09..510fc1ce63 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts @@ -174,66 +174,71 @@ export class DashboardLayoutComponent extends PageComponent implements ILayoutCo private initHotKeys(): void { this.hotKeys.push( - new Hotkey('ctrl+c', (event: KeyboardEvent) => { + new Hotkey(['ctrl+c', 'meta+c'], (event: KeyboardEvent) => { if (this.isEdit && !this.isEditingWidget && !this.widgetEditMode) { const widget = this.dashboard.getSelectedWidget(); if (widget) { event.preventDefault(); this.copyWidget(event, widget); } + return false; } - return false; + return true; }, null, this.translate.instant('action.copy')) ); this.hotKeys.push( - new Hotkey('ctrl+r', (event: KeyboardEvent) => { + new Hotkey(['ctrl+r', 'meta+r'], (event: KeyboardEvent) => { if (this.isEdit && !this.isEditingWidget && !this.widgetEditMode) { const widget = this.dashboard.getSelectedWidget(); if (widget) { event.preventDefault(); this.copyWidgetReference(event, widget); } + return false; } - return false; + return true; }, null, this.translate.instant('action.copy-reference')) ); this.hotKeys.push( - new Hotkey('ctrl+v', (event: KeyboardEvent) => { + new Hotkey(['ctrl+v', 'meta+v'], (event: KeyboardEvent) => { if (this.isEdit && !this.isEditingWidget && !this.widgetEditMode) { if (this.itembuffer.hasWidget()) { event.preventDefault(); this.pasteWidget(event); } + return false; } - return false; + return true; }, null, this.translate.instant('action.paste')) ); this.hotKeys.push( - new Hotkey('ctrl+i', (event: KeyboardEvent) => { + new Hotkey(['ctrl+i', 'meta+i'], (event: KeyboardEvent) => { if (this.isEdit && !this.isEditingWidget && !this.widgetEditMode) { if (this.itembuffer.canPasteWidgetReference(this.dashboardCtx.getDashboard(), this.dashboardCtx.state, this.layoutCtx.id, this.layoutCtx.breakpoint)) { event.preventDefault(); this.pasteWidgetReference(event); } + return false; } - return false; + return true; }, null, this.translate.instant('action.paste-reference')) ); this.hotKeys.push( - new Hotkey('ctrl+x', (event: KeyboardEvent) => { + new Hotkey(['ctrl+x', 'meta+x'], (event: KeyboardEvent) => { if (this.isEdit && !this.isEditingWidget && !this.widgetEditMode) { const widget = this.dashboard.getSelectedWidget(); if (widget) { event.preventDefault(); this.layoutCtx.dashboardCtrl.removeWidget(event, this.layoutCtx, widget); } + return false; } - return false; + return true; }, null, this.translate.instant('action.delete')) ); diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts index d25c6b77cf..cc013fe34d 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts @@ -395,7 +395,7 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo onDashboardMouseDown($event: MouseEvent) { if (this.callbacks && this.callbacks.onDashboardMouseDown) { - if ($event) { + if ($event && this.isEdit) { $event.stopPropagation(); } this.callbacks.onDashboardMouseDown($event); diff --git a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-button.component.html b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-button.component.html index a6033df8cb..4243044fc8 100644 --- a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-button.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-button.component.html @@ -21,7 +21,7 @@ #matButton [class.active]="((isDebugAllActive$ | async) || failuresEnabled) && !disabled" [disabled]="disabled" - (click)="openDebugStrategyPanel($event, matButton)"> + (click)="onOpenDebugStrategyPanel($event, matButton)"> bug_report @if (isDebugAllActive$ | async) { {{ (allEnabled$ | async) === false ? (allEnabledUntil | durationLeft) : (maxDebugModeDuration | milliSecondsToTimeString: true : true) }} diff --git a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-button.component.ts b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-button.component.ts index 26f0c1f642..85a90aef63 100644 --- a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-button.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-button.component.ts @@ -20,15 +20,11 @@ import { Component, forwardRef, Input, - Renderer2, - ViewContainerRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { SharedModule } from '@shared/shared.module'; import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe'; -import { TbPopoverService } from '@shared/components/popover.service'; import { MatButton } from '@angular/material/button'; -import { EntityDebugSettingsPanelComponent } from './entity-debug-settings-panel.component'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { BehaviorSubject, of, shareReplay, timer } from 'rxjs'; import { SECOND, MINUTE } from '@shared/models/time/time.models'; @@ -38,6 +34,8 @@ import { getCurrentAuthState } from '@core/auth/auth.selectors'; import { AppState } from '@core/core.state'; import { Store } from '@ngrx/store'; import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; +import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model'; @Component({ selector: 'tb-entity-debug-settings-button', @@ -54,6 +52,7 @@ import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR } from '@angular/f useExisting: forwardRef(() => EntityDebugSettingsButtonComponent), multi: true }, + EntityDebugSettingsService ], changeDetection: ChangeDetectionStrategy.OnPush }) @@ -61,6 +60,7 @@ export class EntityDebugSettingsButtonComponent implements ControlValueAccessor @Input() debugLimitsConfiguration: string; @Input() entityLabel: string; + @Input() additionalActionConfig: AdditionalDebugActionConfig; debugSettingsFormGroup = this.fb.group({ failuresEnabled: [false], @@ -91,11 +91,9 @@ export class EntityDebugSettingsButtonComponent implements ControlValueAccessor private propagateChange: (settings: EntityDebugSettings) => void = () => {}; - constructor(private popoverService: TbPopoverService, - private renderer: Renderer2, - private store: Store, - private viewContainerRef: ViewContainerRef, + constructor(private store: Store, private fb: FormBuilder, + private entityDebugSettingsService: EntityDebugSettingsService, private cd : ChangeDetectorRef, ) { this.debugSettingsFormGroup.valueChanges.pipe( @@ -117,33 +115,23 @@ export class EntityDebugSettingsButtonComponent implements ControlValueAccessor return this.debugSettingsFormGroup.get('allEnabledUntil').value; } - openDebugStrategyPanel($event: Event, matButton: MatButton): void { + onOpenDebugStrategyPanel($event: Event, matButton: MatButton): void { if ($event) { $event.stopPropagation(); } - const trigger = matButton._elementRef.nativeElement; - const debugSettings = this.debugSettingsFormGroup.value; - - if (this.popoverService.hasPopover(trigger)) { - this.popoverService.hidePopover(trigger); - } else { - const debugStrategyPopover = this.popoverService.displayPopover(trigger, this.renderer, - this.viewContainerRef, EntityDebugSettingsPanelComponent, 'bottom', true, null, - { - ...debugSettings, - maxDebugModeDuration: this.maxDebugModeDuration, - debugLimitsConfiguration: this.debugLimitsConfiguration, - entityLabel: this.entityLabel - }, - {}, - {}, {}, true); - debugStrategyPopover.tbComponentRef.instance.popover = debugStrategyPopover; - debugStrategyPopover.tbComponentRef.instance.onSettingsApplied.subscribe((settings: EntityDebugSettings) => { + this.entityDebugSettingsService.openDebugStrategyPanel({ + debugSettings: this.debugSettingsFormGroup.value, + debugConfig: { + maxDebugModeDuration: this.maxDebugModeDuration, + debugLimitsConfiguration: this.debugLimitsConfiguration, + entityLabel: this.entityLabel, + additionalActionConfig: this.additionalActionConfig, + }, + onSettingsAppliedFn: settings => { this.debugSettingsFormGroup.patchValue(settings); this.cd.markForCheck(); - debugStrategyPopover.hide(); - }); - } + } + }, matButton._elementRef.nativeElement); } registerOnChange(fn: (settings: EntityDebugSettings) => void): void { diff --git a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.html index f7283f0eac..cf8c0ae14d 100644 --- a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.html @@ -48,20 +48,32 @@ -
    - - +
    +
    + @if (additionalActionConfig) { + + } +
    +
    + + +
    diff --git a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.ts index 5b9060704f..1f95a93084 100644 --- a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.ts @@ -21,7 +21,7 @@ import { Component, EventEmitter, Input, - OnInit + OnInit, } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { TbPopoverComponent } from '@shared/components/popover.component'; @@ -34,6 +34,7 @@ import { of, shareReplay, timer } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { EntityDebugSettings } from '@shared/models/entity.models'; import { distinctUntilChanged, map, startWith, switchMap, takeWhile } from 'rxjs/operators'; +import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model'; @Component({ selector: 'tb-entity-debug-settings-panel', @@ -48,13 +49,13 @@ import { distinctUntilChanged, map, startWith, switchMap, takeWhile } from 'rxjs }) export class EntityDebugSettingsPanelComponent extends PageComponent implements OnInit { - @Input() popover: TbPopoverComponent; @Input({ transform: booleanAttribute }) failuresEnabled = false; @Input({ transform: booleanAttribute }) allEnabled = false; @Input() entityLabel: string; @Input() allEnabledUntil = 0; @Input() maxDebugModeDuration: number; @Input() debugLimitsConfiguration: string; + @Input() additionalActionConfig: AdditionalDebugActionConfig; onFailuresControl = this.fb.control(false); debugAllControl = this.fb.control(false); @@ -82,7 +83,8 @@ export class EntityDebugSettingsPanelComponent extends PageComponent implements onSettingsApplied = new EventEmitter(); constructor(private fb: FormBuilder, - private cd: ChangeDetectorRef) { + private cd: ChangeDetectorRef, + private popover: TbPopoverComponent) { super(); this.debugAllControl.valueChanges.pipe( @@ -107,7 +109,7 @@ export class EntityDebugSettingsPanelComponent extends PageComponent implements } onCancel(): void { - this.popover?.hide(); + this.popover.hide(); } onApply(): void { diff --git a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.model.ts b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.model.ts new file mode 100644 index 0000000000..6560580502 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.model.ts @@ -0,0 +1,33 @@ +/// +/// 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 { EntityDebugSettings } from '@shared/models/entity.models'; + +export interface AdditionalDebugActionConfig void> { + action: Action; + title: string; +} + +export interface EntityDebugSettingPanelConfig { + debugSettings: EntityDebugSettings; + debugConfig: { + maxDebugModeDuration: number; + debugLimitsConfiguration: string; + entityLabel?: string; + additionalActionConfig?: AdditionalDebugActionConfig; + } + onSettingsAppliedFn: (settings: EntityDebugSettings) => void; +} diff --git a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.service.ts b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.service.ts new file mode 100644 index 0000000000..873d8f0f3f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.service.ts @@ -0,0 +1,69 @@ +/// +/// 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 { Injectable, Optional, Renderer2, ViewContainerRef } from '@angular/core'; +import { EntityDebugSettingsPanelComponent } from '@home/components/entity/debug/entity-debug-settings-panel.component'; +import { EntityDebugSettings } from '@shared/models/entity.models'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { TranslateService } from '@ngx-translate/core'; +import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe'; +import { EntityDebugSettingPanelConfig } from '@home/components/entity/debug/entity-debug-settings.model'; + +@Injectable() +export class EntityDebugSettingsService { + + constructor( + private popoverService: TbPopoverService, + @Optional() public renderer: Renderer2, + @Optional() public viewContainerRef: ViewContainerRef, + private translate: TranslateService, + private durationLeft: DurationLeftPipe, + ) {} + + openDebugStrategyPanel(panelConfig: EntityDebugSettingPanelConfig, trigger: Element): void { + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const debugStrategyPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, EntityDebugSettingsPanelComponent, 'bottom', true, null, + { + ...panelConfig.debugSettings, + ...panelConfig.debugConfig, + }, + {}, + {}, {}, true); + debugStrategyPopover.tbComponentRef.instance.onSettingsApplied.subscribe(settings => { + panelConfig.onSettingsAppliedFn(settings); + debugStrategyPopover.hide(); + }); + } + } + + + getDebugConfigLabel(debugSettings: EntityDebugSettings): string { + const isDebugActive = this.isDebugActive(debugSettings?.allEnabledUntil); + + if (!isDebugActive) { + return debugSettings?.failuresEnabled ? this.translate.instant('debug-settings.failures') : this.translate.instant('common.disabled'); + } else { + return this.durationLeft.transform(debugSettings?.allEnabledUntil); + } + } + + isDebugActive(allEnabledUntil: number): boolean { + return allEnabledUntil > new Date().getTime(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html index 0523539539..4f53d5f8a3 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html @@ -183,6 +183,7 @@ [class.lt-lg:!hidden]="column.mobileHide" *matCellDef="let entity; let row = index" [matTooltip]="cellTooltip(entity, column, row)" + #cellMatTooltip="matTooltip" matTooltipPosition="above" [style]="cellStyle(entity, column, row)"> @@ -209,6 +210,8 @@ [copyText]="column.actionCell.onAction(null, entity)" tooltipText="{{ column.actionCell.nameFunction ? column.actionCell.nameFunction(entity) : column.actionCell.name }}" tooltipPosition="above" + (mouseover)="cellMatTooltip.hide()" + (mouseleave)="cellMatTooltip.show()" [icon]="column.actionCell.icon" [style]="column.actionCell.style"> diff --git a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts index 5d171555a0..4be96f5fdd 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts @@ -26,7 +26,8 @@ import { OnDestroy, OnInit, SimpleChanges, - ViewChild + ViewChild, + ViewContainerRef, } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; @@ -141,7 +142,8 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa private router: Router, private elementRef: ElementRef, private fb: FormBuilder, - private zone: NgZone) { + private zone: NgZone, + public viewContainerRef: ViewContainerRef) { super(store); } @@ -687,7 +689,7 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa } cellTooltip(entity: BaseData, column: EntityColumn>, row: number) { - if (column instanceof EntityTableColumn) { + if (column instanceof EntityTableColumn || column instanceof EntityLinkTableColumn) { const col = this.entitiesTableConfig.columns.indexOf(column); const index = row * this.entitiesTableConfig.columns.length + col; let res = this.cellTooltipCache[index]; diff --git a/ui-ngx/src/app/modules/home/components/event/event-table-config.ts b/ui-ngx/src/app/modules/home/components/event/event-table-config.ts index 772ef38ab8..5d770fed3b 100644 --- a/ui-ngx/src/app/modules/home/components/event/event-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/event/event-table-config.ts @@ -18,6 +18,7 @@ import { CellActionDescriptorType, DateEntityTableColumn, EntityActionTableColumn, + EntityLinkTableColumn, EntityTableColumn, EntityTableConfig } from '@home/models/entity/entities-table-config.models'; @@ -29,7 +30,7 @@ import { MatDialog } from '@angular/material/dialog'; import { EntityId } from '@shared/models/id/entity-id'; import { EventService } from '@app/core/http/event.service'; import { EventTableHeaderComponent } from '@home/components/event/event-table-header.component'; -import { EntityTypeResource } from '@shared/models/entity-type.models'; +import { EntityType, EntityTypeResource } from '@shared/models/entity-type.models'; import { fromEvent, Observable } from 'rxjs'; import { PageData } from '@shared/models/page/page-data'; import { Direction } from '@shared/models/page/sort-order'; @@ -39,7 +40,7 @@ import { EventContentDialogComponent, EventContentDialogData } from '@home/components/event/event-content-dialog.component'; -import { isEqual, sortObjectKeys } from '@core/utils'; +import { getEntityDetailsPageURL, isEqual, sortObjectKeys } from '@core/utils'; import { DAY, historyInterval, MINUTE } from '@shared/models/time/time.models'; import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; import { ChangeDetectorRef, EventEmitter, Injector, StaticProvider, ViewContainerRef } from '@angular/core'; @@ -355,6 +356,89 @@ export class EventTableConfig extends EntityTableConfig { '48px') ); break; + case DebugEventType.DEBUG_CALCULATED_FIELD: + this.columns[0].width = '80px'; + this.columns[1].width = '100px'; + this.columns.push( + new EntityLinkTableColumn('entityId', 'event.entity-id', '100px', + (entity) => `${entity.body.entityId.substring(0, 8)}…`, + (entity) => getEntityDetailsPageURL(entity.body.entityId, entity.body.entityType as EntityType), + false, + () => ({padding: '0 12px 0 0'}), + () => ({padding: '0 12px 0 0'}), + (entity) => entity.body.entityId, + { + name: this.translate.instant('event.copy-entity-id'), + icon: 'content_copy', + style: { + padding: '4px', + 'font-size': '16px', + color: 'rgba(0,0,0,.87)' + }, + isEnabled: () => true, + onAction: ($event, entity) => entity.body.entityId, + type: CellActionDescriptorType.COPY_BUTTON + } + ), + new EntityTableColumn('messageId', 'event.message-id', '100px', + (entity) => entity.body.msgId ? `${entity.body.msgId?.substring(0, 8)}…` : '-', + () => ({padding: '0 12px 0 0'}), + false, + () => ({padding: '0 12px 0 0'}), + (entity) => entity.body.msgId, + false, + { + name: this.translate.instant('event.copy-message-id'), + icon: 'content_copy', + style: { + padding: '4px', + 'font-size': '16px', + color: 'rgba(0,0,0,.87)' + }, + isEnabled: (entity) => !!entity.body.msgId, + onAction: (_, entity) => entity.body.msgId, + type: CellActionDescriptorType.COPY_BUTTON + } + ), + new EntityTableColumn('messageType', 'event.message-type', '100px', + (entity) => entity.body.msgType ?? '-', + () => ({padding: '0 12px 0 0'}), + false, + () => ({padding: '0 12px 0 0'}), + (entity) => entity.body.msgType, + ), + new EntityActionTableColumn('arguments', 'event.arguments', + { + name: this.translate.instant('action.view'), + icon: 'more_horiz', + isEnabled: (entity) => entity.body.arguments !== undefined, + onAction: ($event, entity) => this.showContent($event, entity.body.arguments, + 'event.arguments', ContentType.JSON, true) + }, + '48px' + ), + new EntityActionTableColumn('result', 'event.result', + { + name: this.translate.instant('action.view'), + icon: 'more_horiz', + isEnabled: (entity) => entity.body.result !== undefined, + onAction: ($event, entity) => this.showContent($event, entity.body.result, + 'event.result', ContentType.JSON, true) + }, + '48px' + ), + new EntityActionTableColumn('error', 'event.error', + { + name: this.translate.instant('action.view'), + icon: 'more_horiz', + isEnabled: (entity) => entity.body.error && entity.body.error.length > 0, + onAction: ($event, entity) => this.showContent($event, entity.body.error, + 'event.error') + }, + '48px' + ) + ); + break; } if (updateTableColumns) { this.getTable().columnsUpdated(true); @@ -376,6 +460,14 @@ export class EventTableConfig extends EntityTableConfig { }); } break; + case DebugEventType.DEBUG_CALCULATED_FIELD: + this.cellActionDescriptors.push({ + name: this.translate.instant('common.test-with-this-message', {test: this.translate.instant(this.testButtonLabel)}), + icon: 'bug_report', + isEnabled: () => true, + onAction: (_, entity) => this.debugEventSelected.next(entity.body) + }); + break; } this.getTable()?.cellActionDescriptorsUpdated(); } @@ -386,7 +478,12 @@ export class EventTableConfig extends EntityTableConfig { } if (contentType === ContentType.JSON && sortKeys) { try { - content = JSON.stringify(sortObjectKeys(JSON.parse(content))); + const parsedContent = JSON.parse(content); + if (Array.isArray(parsedContent)) { + content = JSON.stringify(parsedContent.map(item => item && typeof item === 'object' ? sortObjectKeys(item) : item)); + } else { + content = JSON.stringify(sortObjectKeys(parsedContent)); + } } catch (e) {} } this.dialog.open(EventContentDialogComponent, { @@ -446,6 +543,17 @@ export class EventTableConfig extends EntityTableConfig { {key: 'errorStr', title: 'event.error'} ); break; + case DebugEventType.DEBUG_CALCULATED_FIELD: + this.filterColumns.push( + {key: 'entityId', title: 'event.entity-id'}, + {key: 'msgId', title: 'event.message-id'}, + {key: 'msgType', title: 'event.message-type'}, + {key: 'arguments', title: 'event.arguments'}, + {key: 'result', title: 'event.result'}, + {key: 'isError', title: 'event.error'}, + {key: 'errorStr', title: 'event.error'} + ); + break; } } diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 7fdcccad64..ac0296e2a5 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -183,6 +183,26 @@ import { } from '@home/components/dashboard-page/layout/select-dashboard-breakpoint.component'; import { EntityChipsComponent } from '@home/components/entity/entity-chips.component'; import { DashboardViewComponent } from '@home/components/dashboard-view/dashboard-view.component'; +import { CalculatedFieldsTableComponent } from '@home/components/calculated-fields/calculated-fields-table.component'; +import { CalculatedFieldDialogComponent } from '@home/components/calculated-fields/components/dialog/calculated-field-dialog.component'; +import { + EntityDebugSettingsButtonComponent +} from '@home/components/entity/debug/entity-debug-settings-button.component'; +import { + CalculatedFieldArgumentsTableComponent +} from '@home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component'; +import { + CalculatedFieldArgumentPanelComponent +} from '@home/components/calculated-fields/components/panel/calculated-field-argument-panel.component'; +import { + CalculatedFieldDebugDialogComponent +} from '@home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component'; +import { + CalculatedFieldScriptTestDialogComponent +} from '@home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component'; +import { + CalculatedFieldTestArgumentsComponent +} from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component'; @NgModule({ declarations: @@ -326,7 +346,14 @@ import { DashboardViewComponent } from '@home/components/dashboard-view/dashboar RateLimitsDetailsDialogComponent, SendNotificationButtonComponent, EntityChipsComponent, - DashboardViewComponent + DashboardViewComponent, + CalculatedFieldsTableComponent, + CalculatedFieldDialogComponent, + CalculatedFieldArgumentsTableComponent, + CalculatedFieldArgumentPanelComponent, + CalculatedFieldDebugDialogComponent, + CalculatedFieldScriptTestDialogComponent, + CalculatedFieldTestArgumentsComponent, ], imports: [ CommonModule, @@ -338,7 +365,8 @@ import { DashboardViewComponent } from '@home/components/dashboard-view/dashboar SnmpDeviceProfileTransportModule, StatesControllerModule, DeviceCredentialsModule, - DeviceProfileCommonModule + DeviceProfileCommonModule, + EntityDebugSettingsButtonComponent ], exports: [ RouterTabsComponent, @@ -463,7 +491,14 @@ import { DashboardViewComponent } from '@home/components/dashboard-view/dashboar RateLimitsDetailsDialogComponent, SendNotificationButtonComponent, EntityChipsComponent, - DashboardViewComponent + DashboardViewComponent, + CalculatedFieldsTableComponent, + CalculatedFieldDialogComponent, + CalculatedFieldArgumentsTableComponent, + CalculatedFieldArgumentPanelComponent, + CalculatedFieldDebugDialogComponent, + CalculatedFieldScriptTestDialogComponent, + CalculatedFieldTestArgumentsComponent, ], providers: [ WidgetComponentService, diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.scss b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.scss index 910c6625db..d86864d1f3 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.scss +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.scss @@ -15,7 +15,7 @@ */ :host{ .tb-panel { - min-height: 42px; + min-height: 48px; .tb-panel-title { font-weight: 500; diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html index 9f1839afec..aded3e2f78 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html @@ -155,10 +155,10 @@ formControlName="maxTransportMessages" type="number"> - {{ 'tenant-profile.max-transport-messages-range' | translate}} + {{ 'tenant-profile.max-transport-messages-required' | translate}} - {{ 'tenant-profile.max-transport-messages-required' | translate}} + {{ 'tenant-profile.max-transport-messages-range' | translate}} @@ -229,6 +229,92 @@ +
    + + {{ 'tenant-profile.calculated-fields' | translate }} tenant-profile.unlimited + +
    + + tenant-profile.max-calculated-fields + + + {{ 'tenant-profile.max-calculated-fields-required' | translate}} + + + {{ 'tenant-profile.max-calculated-fields-range' | translate}} + + + + + tenant-profile.max-data-points-per-rolling-arg + + + {{ 'tenant-profile.max-data-points-per-rolling-arg-required' | translate}} + + + {{ 'tenant-profile.max-data-points-per-rolling-arg-range' | translate}} + + + +
    +
    + + tenant-profile.max-arguments-per-cf + + + {{ 'tenant-profile.max-arguments-per-cf-required' | translate}} + + + {{ 'tenant-profile.max-arguments-per-cf-range' | translate}} + + + +
    +
    + + + + tenant-profile.advanced-settings + + + +
    + + tenant-profile.max-state-size + + + {{ 'tenant-profile.max-state-size-required' | translate}} + + + {{ 'tenant-profile.max-state-size-range' | translate}} + + + + + tenant-profile.max-value-argument-size + + + {{ 'tenant-profile.max-value-argument-size-required' | translate}} + + + {{ 'tenant-profile.max-value-argument-size-range' | translate}} + + + +
    +
    +
    +
    @@ -638,6 +724,12 @@ [type]="rateLimitsType.EDGE_UPLINK_MESSAGES_PER_EDGE_RATE_LIMIT"> +
    + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts index 4cce34c502..b1d6652e4a 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts @@ -118,7 +118,13 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA edgeEventRateLimits: [null, []], edgeEventRateLimitsPerEdge: [null, []], edgeUplinkMessagesRateLimits: [null, []], - edgeUplinkMessagesRateLimitsPerEdge: [null, []] + edgeUplinkMessagesRateLimitsPerEdge: [null, []], + maxCalculatedFieldsPerEntity: [null, [Validators.required, Validators.min(0)]], + maxArgumentsPerCF: [null, [Validators.required, Validators.min(0)]], + maxDataPointsPerRollingArg: [null, [Validators.required, Validators.min(0)]], + maxStateSizeInKBytes: [null, [Validators.required, Validators.min(0)]], + calculatedFieldDebugEventsRateLimit: [null, []], + maxSingleValueArgumentSizeInKBytes: [null, [Validators.required, Validators.min(0)]], }); this.defaultTenantProfileConfigurationFormGroup.get('smsEnabled').valueChanges.pipe( diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts index ab50c967bf..f09f950ee7 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts @@ -45,7 +45,8 @@ export enum RateLimitsType { EDGE_EVENTS_RATE_LIMIT = 'EDGE_EVENTS_RATE_LIMIT', EDGE_EVENTS_PER_EDGE_RATE_LIMIT = 'EDGE_EVENTS_PER_EDGE_RATE_LIMIT', EDGE_UPLINK_MESSAGES_RATE_LIMIT = 'EDGE_UPLINK_MESSAGES_RATE_LIMIT', - EDGE_UPLINK_MESSAGES_PER_EDGE_RATE_LIMIT = 'EDGE_UPLINK_MESSAGES_PER_EDGE_RATE_LIMIT' + EDGE_UPLINK_MESSAGES_PER_EDGE_RATE_LIMIT = 'EDGE_UPLINK_MESSAGES_PER_EDGE_RATE_LIMIT', + CALCULATED_FIELD_DEBUG_EVENT_RATE_LIMIT = 'CALCULATED_FIELD_DEBUG_EVENT_RATE_LIMIT', } export const rateLimitsLabelTranslationMap = new Map( @@ -74,6 +75,7 @@ export const rateLimitsLabelTranslationMap = new Map( [RateLimitsType.EDGE_EVENTS_PER_EDGE_RATE_LIMIT, 'tenant-profile.rate-limits.edge-events-per-edge-rate-limit'], [RateLimitsType.EDGE_UPLINK_MESSAGES_RATE_LIMIT, 'tenant-profile.rate-limits.edge-uplink-messages-rate-limit'], [RateLimitsType.EDGE_UPLINK_MESSAGES_PER_EDGE_RATE_LIMIT, 'tenant-profile.rate-limits.edge-uplink-messages-per-edge-rate-limit'], + [RateLimitsType.CALCULATED_FIELD_DEBUG_EVENT_RATE_LIMIT, 'tenant-profile.rate-limits.calculated-field-debug-event-rate-limit'], ] ); @@ -103,6 +105,7 @@ export const rateLimitsDialogTitleTranslationMap = new Map AdvancedPersistenceSettingComponent), - multi: true - },{ - provide: NG_VALIDATORS, - useExisting: forwardRef(() => AdvancedPersistenceSettingComponent), - multi: true - }] -}) -export class AdvancedPersistenceSettingComponent implements ControlValueAccessor, Validator { - - persistenceForm = this.fb.group({ - timeseries: [null], - latest: [null], - webSockets: [null] - }); - - private propagateChange: (value: any) => void = () => {}; - - constructor(private fb: FormBuilder) { - this.persistenceForm.valueChanges.pipe( - takeUntilDestroyed() - ).subscribe(value => this.propagateChange(value)); - } - - registerOnChange(fn: any) { - this.propagateChange = fn; - } - - registerOnTouched(_fn: any) { - } - - setDisabledState(isDisabled: boolean) { - if (isDisabled) { - this.persistenceForm.disable({emitEvent: false}); - } else { - this.persistenceForm.enable({emitEvent: false}); - } - } - - validate(): ValidationErrors | null { - return this.persistenceForm.valid ? null : { - persistenceForm: false - }; - } - - writeValue(value: AdvancedProcessingStrategy) { - this.persistenceForm.patchValue(value, {emitEvent: false}); - } -} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-persistence-setting-row.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.html similarity index 80% rename from ui-ngx/src/app/modules/home/components/rule-node/action/advanced-persistence-setting-row.component.html rename to ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.html index 635f200942..168c0f7340 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-persistence-setting-row.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.html @@ -15,17 +15,17 @@ limitations under the License. --> -
    +
    {{ title }}
    rule-node-config.save-time-series.strategy - @for (strategy of persistenceStrategies; track strategy) { - {{ PersistenceTypeTranslationMap.get(strategy) | translate }} + @for (strategy of processingStrategies; track strategy) { + {{ ProcessingTypeTranslationMap.get(strategy) | translate }} } - @if(persistenceSettingRowForm.get('type').value === PersistenceType.DEDUPLICATE) { + @if (processingSettingRowForm.get('type').value === ProcessingType.DEDUPLICATE) { AdvancedPersistenceSettingRowComponent), + useExisting: forwardRef(() => AdvancedProcessingSettingRowComponent), multi: true },{ provide: NG_VALIDATORS, - useExisting: forwardRef(() => AdvancedPersistenceSettingRowComponent), + useExisting: forwardRef(() => AdvancedProcessingSettingRowComponent), multi: true }] }) -export class AdvancedPersistenceSettingRowComponent implements ControlValueAccessor, Validator { +export class AdvancedProcessingSettingRowComponent implements ControlValueAccessor, Validator { @Input() title: string; - persistenceSettingRowForm = this.fb.group({ + processingSettingRowForm = this.fb.group({ type: [defaultAdvancedProcessingConfig.type], deduplicationIntervalSecs: [{value: 60, disabled: true}] }); - PersistenceType = ProcessingType; - persistenceStrategies = [ProcessingType.ON_EVERY_MESSAGE, ProcessingType.DEDUPLICATE, ProcessingType.SKIP]; - PersistenceTypeTranslationMap = ProcessingTypeTranslationMap; + ProcessingType = ProcessingType; + processingStrategies = [ProcessingType.ON_EVERY_MESSAGE, ProcessingType.DEDUPLICATE, ProcessingType.SKIP]; + ProcessingTypeTranslationMap = ProcessingTypeTranslationMap; maxDeduplicateTime = maxDeduplicateTimeSecs; private propagateChange: (value: any) => void = () => {}; constructor(private fb: FormBuilder) { - this.persistenceSettingRowForm.get('type').valueChanges.pipe( + this.processingSettingRowForm.get('type').valueChanges.pipe( takeUntilDestroyed() ).subscribe(() => this.updatedValidation()); - this.persistenceSettingRowForm.valueChanges.pipe( + this.processingSettingRowForm.valueChanges.pipe( takeUntilDestroyed() ).subscribe((value) => this.propagateChange(value)); } @@ -83,32 +83,32 @@ export class AdvancedPersistenceSettingRowComponent implements ControlValueAcces setDisabledState(isDisabled: boolean) { if (isDisabled) { - this.persistenceSettingRowForm.disable({emitEvent: false}); + this.processingSettingRowForm.disable({emitEvent: false}); } else { - this.persistenceSettingRowForm.enable({emitEvent: false}); + this.processingSettingRowForm.enable({emitEvent: false}); this.updatedValidation(); } } validate(): ValidationErrors | null { - return this.persistenceSettingRowForm.valid ? null : { - persistenceSettingRow: false + return this.processingSettingRowForm.valid ? null : { + processingSettingRow: false }; } writeValue(value: AdvancedProcessingConfig) { if (isDefinedAndNotNull(value)) { - this.persistenceSettingRowForm.patchValue(value, {emitEvent: false}); + this.processingSettingRowForm.patchValue(value, {emitEvent: false}); } else { - this.persistenceSettingRowForm.patchValue(defaultAdvancedProcessingConfig); + this.processingSettingRowForm.patchValue(defaultAdvancedProcessingConfig); } } private updatedValidation() { - if (this.persistenceSettingRowForm.get('type').value === ProcessingType.DEDUPLICATE) { - this.persistenceSettingRowForm.get('deduplicationIntervalSecs').enable({emitEvent: false}); + if (this.processingSettingRowForm.get('type').value === ProcessingType.DEDUPLICATE) { + this.processingSettingRowForm.get('deduplicationIntervalSecs').enable({emitEvent: false}); } else { - this.persistenceSettingRowForm.get('deduplicationIntervalSecs').disable({emitEvent: false}) + this.processingSettingRowForm.get('deduplicationIntervalSecs').disable({emitEvent: false}) } } } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-persistence-setting.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html similarity index 53% rename from ui-ngx/src/app/modules/home/components/rule-node/action/advanced-persistence-setting.component.html rename to ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html index 094f6dbc2a..edce8b12a9 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-persistence-setting.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html @@ -15,22 +15,30 @@ limitations under the License. --> -
    +
    - - + + - + + > +
    diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.ts new file mode 100644 index 0000000000..4946de986a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.ts @@ -0,0 +1,121 @@ +/// +/// 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 { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormGroup, + ValidationErrors, + Validator +} from '@angular/forms'; +import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AdvancedProcessingStrategy } from '@home/components/rule-node/action/timeseries-config.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { AttributeAdvancedProcessingStrategy } from '@home/components/rule-node/action/attributes-config.model'; + +@Component({ + selector: 'tb-advanced-processing-settings', + templateUrl: './advanced-processing-setting.component.html', + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AdvancedProcessingSettingComponent), + multi: true + },{ + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AdvancedProcessingSettingComponent), + multi: true + }] +}) +export class AdvancedProcessingSettingComponent implements OnInit, ControlValueAccessor, Validator { + + @Input() + @coerceBoolean() + timeseries = false; + + @Input() + @coerceBoolean() + attributes = false; + + @Input() + @coerceBoolean() + latest = false; + + @Input() + @coerceBoolean() + webSockets = false; + + @Input() + @coerceBoolean() + calculatedFields = false; + + processingForm: UntypedFormGroup; + + private propagateChange: (value: any) => void = () => {}; + + constructor(private fb: FormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + this.processingForm = this.fb.group({}); + if (this.timeseries) { + this.processingForm.addControl('timeseries', this.fb.control(null, [])); + } + if (this.attributes) { + this.processingForm.addControl('attributes', this.fb.control(null, [])); + } + if (this.latest) { + this.processingForm.addControl('latest', this.fb.control(null, [])); + } + if (this.webSockets) { + this.processingForm.addControl('webSockets', this.fb.control(null, [])); + } + if (this.calculatedFields) { + this.processingForm.addControl('calculatedFields', this.fb.control(null, [])); + } + this.processingForm.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(value => this.propagateChange(value)); + } + + registerOnChange(fn: any) { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any) { + } + + setDisabledState(isDisabled: boolean) { + if (isDisabled) { + this.processingForm.disable({emitEvent: false}); + } else { + this.processingForm.enable({emitEvent: false}); + } + } + + validate(): ValidationErrors | null { + return this.processingForm.valid ? null : { + processingForm: false + }; + } + + writeValue(value: AdvancedProcessingStrategy | AttributeAdvancedProcessingStrategy) { + this.processingForm.patchValue(value, {emitEvent: false}); + } +} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html index 03ea4fccdd..0de3f8020f 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html @@ -16,11 +16,54 @@ -->
    +
    +
    +
    + rule-node-config.save-attribute.processing-settings +
    + + {{ 'rule-node-config.basic-mode' | translate}} + {{ 'rule-node-config.advanced-mode' | translate }} + +
    + @if(!attributesConfigForm.get('processingSettings.isAdvanced').value) { + + rule-node-config.save-attribute.strategy + + @for (strategy of processingStrategies; track strategy) { + {{ ProcessingTypeTranslationMap.get(strategy) | translate }} + } + + + + @if(attributesConfigForm.get('processingSettings.type').value === ProcessingType.DEDUPLICATE) { + + + } + } @else { + + } +
    +
    - - +
    + rule-node-config.save-attribute.scope +
    - + {{ 'rule-node-config.attributes-scope' | translate }} @@ -29,7 +72,7 @@ - + {{ 'rule-node-config.attributes-scope-value' | translate }}
    diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.ts index 47c0501b26..7f27bca241 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.ts @@ -18,7 +18,7 @@ import { Component } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; import { - defaultAdvancedPersistenceStrategy, + defaultAdvancedProcessingStrategy, maxDeduplicateTimeSecs, ProcessingSettings, ProcessingSettingsForm, @@ -37,9 +37,9 @@ export class TimeseriesConfigComponent extends RuleNodeConfigurationComponent { timeseriesConfigForm: FormGroup; - PersistenceType = ProcessingType; - persistenceStrategies = [ProcessingType.ON_EVERY_MESSAGE, ProcessingType.DEDUPLICATE, ProcessingType.WEBSOCKETS_ONLY]; - PersistenceTypeTranslationMap = ProcessingTypeTranslationMap; + ProcessingType = ProcessingType; + processingStrategies = [ProcessingType.ON_EVERY_MESSAGE, ProcessingType.DEDUPLICATE, ProcessingType.WEBSOCKETS_ONLY]; + ProcessingTypeTranslationMap = ProcessingTypeTranslationMap; maxDeduplicateTime = maxDeduplicateTimeSecs @@ -63,14 +63,14 @@ export class TimeseriesConfigComponent extends RuleNodeConfigurationComponent { type: isAdvanced ? ProcessingType.ON_EVERY_MESSAGE : config.processingSettings.type, isAdvanced: isAdvanced, deduplicationIntervalSecs: config.processingSettings?.deduplicationIntervalSecs ?? 60, - advanced: isAdvanced ? config.processingSettings : defaultAdvancedPersistenceStrategy + advanced: isAdvanced ? config.processingSettings : defaultAdvancedProcessingStrategy } } else { processingSettings = { type: ProcessingType.ON_EVERY_MESSAGE, isAdvanced: false, deduplicationIntervalSecs: 60, - advanced: defaultAdvancedPersistenceStrategy + advanced: defaultAdvancedProcessingStrategy }; } return { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.models.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.models.ts index f8987b7f0e..9785125999 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.models.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.models.ts @@ -55,14 +55,15 @@ export interface BasicProcessingSettings { type: ProcessingType; } -export interface DeduplicateProcessingStrategy extends BasicProcessingSettings{ +export interface DeduplicateProcessingStrategy extends BasicProcessingSettings { deduplicationIntervalSecs: number; } -export interface AdvancedProcessingStrategy extends BasicProcessingSettings{ +export interface AdvancedProcessingStrategy extends BasicProcessingSettings { timeseries: AdvancedProcessingConfig; latest: AdvancedProcessingConfig; webSockets: AdvancedProcessingConfig; + calculatedFields: AdvancedProcessingConfig; } export type AdvancedProcessingConfig = WithOptional; @@ -71,8 +72,9 @@ export const defaultAdvancedProcessingConfig: AdvancedProcessingConfig = { type: ProcessingType.ON_EVERY_MESSAGE } -export const defaultAdvancedPersistenceStrategy: Omit = { +export const defaultAdvancedProcessingStrategy: Omit = { timeseries: defaultAdvancedProcessingConfig, latest: defaultAdvancedProcessingConfig, webSockets: defaultAdvancedProcessingConfig, + calculatedFields: defaultAdvancedProcessingConfig, } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html index 3aa743247f..1132c9982c 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html @@ -73,20 +73,6 @@ - - rule-node-config.key-serializer - - - {{ 'rule-node-config.key-serializer-required' | translate }} - - - - rule-node-config.value-serializer - - - {{ 'rule-node-config.value-serializer-required' | translate }} - - {{ 'version-control.export-relations' | translate }} + + {{ 'version-control.export-calculated-fields' | translate }} +
    diff --git a/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.ts b/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.ts index e0a88a3e09..d55515a959 100644 --- a/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.ts @@ -25,7 +25,11 @@ import { TranslateService } from '@ngx-translate/core'; import { DialogService } from '@core/services/dialog.service'; import { catchError, map, mergeMap } from 'rxjs/operators'; import { Observable, of } from 'rxjs'; -import { EntityTypeVersionCreateConfig, exportableEntityTypes } from '@shared/models/vc.models'; +import { + EntityTypeVersionCreateConfig, + exportableEntityTypes, + typesWithCalculatedFields +} from '@shared/models/vc.models'; import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; @@ -43,6 +47,8 @@ export class AutoCommitSettingsComponent extends PageComponent implements OnInit isReadOnly: Observable; + readonly typesWithCalculatedFields = typesWithCalculatedFields; + constructor(protected store: Store, private adminService: AdminService, private dialogService: DialogService, @@ -104,7 +110,8 @@ export class AutoCommitSettingsComponent extends PageComponent implements OnInit branch: null, saveAttributes: true, saveRelations: false, - saveCredentials: true + saveCredentials: true, + saveCalculatedFields: true, }; const allowed = this.allowedEntityTypes(); let entityType: EntityType = null; @@ -206,7 +213,8 @@ export class AutoCommitSettingsComponent extends PageComponent implements OnInit branch: [config.branch, []], saveRelations: [config.saveRelations, []], saveAttributes: [config.saveAttributes, []], - saveCredentials: [config.saveCredentials, []] + saveCredentials: [config.saveCredentials, []], + saveCalculatedFields: [config.saveCalculatedFields, []] }) } ); diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.html b/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.html index b9a9a6c01c..8fdf22f6cd 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.html @@ -72,6 +72,9 @@ {{ 'version-control.export-relations' | translate }} + + {{ 'version-control.export-calculated-fields' | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.ts b/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.ts index 8b16905587..8618e405b0 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.ts @@ -33,7 +33,8 @@ import { EntityTypeVersionCreateConfig, exportableEntityTypes, SyncStrategy, - syncStrategyTranslationMap + syncStrategyTranslationMap, + typesWithCalculatedFields } from '@shared/models/vc.models'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -79,6 +80,8 @@ export class EntityTypesVersionCreateComponent extends PageComponent implements loading = true; + readonly typesWithCalculatedFields = typesWithCalculatedFields; + constructor(protected store: Store, private translate: TranslateService, private fb: UntypedFormBuilder, @@ -150,6 +153,7 @@ export class EntityTypesVersionCreateComponent extends PageComponent implements saveRelations: [config.saveRelations, []], saveAttributes: [config.saveAttributes, []], saveCredentials: [config.saveCredentials, []], + saveCalculatedFields: [config.saveCalculatedFields, []], allEntities: [config.allEntities, []], entityIds: [config.entityIds, [Validators.required]] }) @@ -202,6 +206,7 @@ export class EntityTypesVersionCreateComponent extends PageComponent implements saveAttributes: true, saveRelations: true, saveCredentials: true, + saveCalculatedFields: true, allEntities: true, entityIds: [] }; diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-types-version-load.component.html b/ui-ngx/src/app/modules/home/components/vc/entity-types-version-load.component.html index 4736bb1ee2..b877d19b63 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-types-version-load.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/entity-types-version-load.component.html @@ -72,6 +72,9 @@ {{ 'version-control.load-relations' | translate }} + + {{ 'version-control.load-calculated-fields' | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-types-version-load.component.ts b/ui-ngx/src/app/modules/home/components/vc/entity-types-version-load.component.ts index 23f09ae977..f06abaf3a8 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-types-version-load.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/entity-types-version-load.component.ts @@ -31,7 +31,8 @@ import { PageComponent } from '@shared/components/page.component'; import { entityTypesWithoutRelatedData, EntityTypeVersionLoadConfig, - exportableEntityTypes + exportableEntityTypes, + typesWithCalculatedFields } from '@shared/models/vc.models'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -75,6 +76,8 @@ export class EntityTypesVersionLoadComponent extends PageComponent implements On loading = true; + readonly typesWithCalculatedFields = typesWithCalculatedFields; + constructor(protected store: Store, private translate: TranslateService, private popoverService: TbPopoverService, @@ -145,6 +148,7 @@ export class EntityTypesVersionLoadComponent extends PageComponent implements On loadRelations: [config.loadRelations, []], loadAttributes: [config.loadAttributes, []], loadCredentials: [config.loadCredentials, []], + loadCalculatedFields: [config.loadCalculatedFields, []], removeOtherEntities: [config.removeOtherEntities, []], findExistingEntityByName: [config.findExistingEntityByName, []] }) @@ -180,6 +184,7 @@ export class EntityTypesVersionLoadComponent extends PageComponent implements On loadAttributes: true, loadRelations: true, loadCredentials: true, + loadCalculatedFields: true, removeOtherEntities: false, findExistingEntityByName: true }; diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-version-create.component.html b/ui-ngx/src/app/modules/home/components/vc/entity-version-create.component.html index 10c2dc5e40..da4c31a1b8 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-version-create.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/entity-version-create.component.html @@ -47,6 +47,9 @@ {{ 'version-control.export-relations' | translate }} + + {{ 'version-control.export-calculated-fields' | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-version-create.component.ts b/ui-ngx/src/app/modules/home/components/vc/entity-version-create.component.ts index 48b2a2a174..2042b7fdb6 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-version-create.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/entity-version-create.component.ts @@ -20,6 +20,7 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms import { entityTypesWithoutRelatedData, SingleEntityVersionCreateRequest, + typesWithCalculatedFields, VersionCreateRequestType, VersionCreationResult } from '@shared/models/vc.models'; @@ -71,6 +72,8 @@ export class EntityVersionCreateComponent extends PageComponent implements OnIni private versionCreateResultSubscription: Subscription; + readonly typesWithCalculatedFields = typesWithCalculatedFields; + constructor(protected store: Store, private entitiesVersionControlService: EntitiesVersionControlService, private cd: ChangeDetectorRef, @@ -86,7 +89,8 @@ export class EntityVersionCreateComponent extends PageComponent implements OnIni {entityName: this.entityName}), [Validators.required, Validators.pattern(/(?:.|\s)*\S(&:.|\s)*/)]], saveRelations: [false, []], saveAttributes: [true, []], - saveCredentials: [true, []] + saveCredentials: [true, []], + saveCalculatedFields: [true, []] }); } @@ -115,7 +119,8 @@ export class EntityVersionCreateComponent extends PageComponent implements OnIni ? this.createVersionFormGroup.get('saveRelations').value : false, saveAttributes: !entityTypesWithoutRelatedData.has(this.entityId.entityType) ? this.createVersionFormGroup.get('saveAttributes').value : false, - saveCredentials: this.entityId.entityType === EntityType.DEVICE ? this.createVersionFormGroup.get('saveCredentials').value : false + saveCredentials: this.entityId.entityType === EntityType.DEVICE ? this.createVersionFormGroup.get('saveCredentials').value : false, + saveCalculatedFields: typesWithCalculatedFields.has(this.entityId.entityType) ? this.createVersionFormGroup.get('saveCalculatedFields').value : false, }, type: VersionCreateRequestType.SINGLE_ENTITY }; diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.html b/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.html index 4bc61757c4..1183223850 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.html @@ -36,6 +36,9 @@ {{ 'version-control.load-relations' | translate }} + + {{ 'version-control.load-calculated-fields' | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.ts b/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.ts index df2e8c19ff..4456eb7ce7 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.ts @@ -79,7 +79,8 @@ export class EntityVersionRestoreComponent extends PageComponent implements OnIn this.restoreFormGroup = this.fb.group({ loadAttributes: [true, []], loadRelations: [true, []], - loadCredentials: [true, []] + loadCredentials: [true, []], + loadCalculatedFields: [true, []] }); this.entitiesVersionControlService.getEntityDataInfo(this.externalEntityId, this.versionId).subscribe((data) => { this.entityDataInfo = data; @@ -110,7 +111,8 @@ export class EntityVersionRestoreComponent extends PageComponent implements OnIn config: { loadRelations: this.entityDataInfo.hasRelations ? this.restoreFormGroup.get('loadRelations').value : false, loadAttributes: this.entityDataInfo.hasAttributes ? this.restoreFormGroup.get('loadAttributes').value : false, - loadCredentials: this.entityDataInfo.hasCredentials ? this.restoreFormGroup.get('loadCredentials').value : false + loadCredentials: this.entityDataInfo.hasCredentials ? this.restoreFormGroup.get('loadCredentials').value : false, + loadCalculatedFields: this.entityDataInfo.hasCalculatedFields ? this.restoreFormGroup.get('loadCalculatedFields').value : false }, type: VersionLoadRequestType.SINGLE_ENTITY }; diff --git a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.html index 5b978aaa13..3d88f08c8d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.html @@ -33,6 +33,7 @@ [callbacks]="data.callbacks" [widgetType] = "data.widgetType" [actionSources]="actionSources" + [additionalWidgetActionTypes]="data.additionalWidgetActionTypes" formControlName="actions"> diff --git a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.ts index cdc77f51a7..bc3de16c69 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { widgetType } from '@shared/models/widget.models'; +import { WidgetActionType, widgetType } from '@shared/models/widget.models'; import { WidgetActionCallbacks, WidgetActionsData @@ -32,6 +32,7 @@ export interface ManageWidgetActionsDialogData { actionsData: WidgetActionsData; callbacks: WidgetActionCallbacks; widgetType: widgetType; + additionalWidgetActionTypes?: WidgetActionType[]; } @Component({ diff --git a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts index b3d8c5e654..730be78656 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts @@ -46,7 +46,7 @@ import { WidgetActionsDatasource } from '@home/components/widget/action/manage-widget-actions.component.models'; import { UtilsService } from '@core/services/utils.service'; -import { WidgetActionDescriptor, WidgetActionSource, widgetType } from '@shared/models/widget.models'; +import { WidgetActionDescriptor, WidgetActionSource, WidgetActionType, widgetType } from '@shared/models/widget.models'; import { WidgetActionDialogComponent, WidgetActionDialogData @@ -77,6 +77,8 @@ export class ManageWidgetActionsComponent extends PageComponent implements OnIni @Input() actionSources: {[actionSourceId: string]: WidgetActionSource}; + @Input() additionalWidgetActionTypes: WidgetActionType[]; + innerValue: WidgetActionsData; displayedColumns: string[]; @@ -236,7 +238,8 @@ export class ManageWidgetActionsComponent extends PageComponent implements OnIni callbacks: this.callbacks, actionsData, action: deepClone(action), - widgetType: this.widgetType + widgetType: this.widgetType, + additionalWidgetActionTypes: this.additionalWidgetActionTypes } }).afterClosed().subscribe( (res) => { diff --git a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html index ec1b165401..e5d006076e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html @@ -88,12 +88,74 @@ -
    -
    {{'widget-config.icon' | translate}}
    - - -
    + @if (widgetActionFormGroup.get('actionSourceId').value === 'headerButton') { +
    +
    widget-config.header-button.button-settings
    +
    +
    widget-config.header-button.button-type
    + + + + {{ widgetHeaderActionButtonTypeTranslationMap.get(widgetHeaderActionButtonType[button]) | translate }} + + + +
    +
    +
    + + +
    widget-config.icon
    +
    + + +
    +
    +
    {{ 'widget-config.header-button.colors' | translate }}
    +
    +
    widget-config.header-button.color
    + + + +
    widget-config.header-button.background
    + + + +
    widget-config.header-button.border
    + + +
    +
    +
    + + + + widget-config.header-button.advanced-button-style + + + + + + +
    +
    + } @else { +
    +
    {{'widget-config.icon' | translate}}
    + + +
    + } +
    @@ -121,7 +183,8 @@ + [widgetType]="data.widgetType" + [additionalWidgetActionTypes]="data.additionalWidgetActionTypes">
    diff --git a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts index df8df4a882..5dd2276591 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, Inject, OnDestroy, OnInit, SkipSelf, ViewChild } from '@angular/core'; +import { Component, DestroyRef, Inject, OnInit, SkipSelf, ViewChild } from '@angular/core'; import { ErrorStateMatcher } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; @@ -28,7 +28,6 @@ import { ValidatorFn, Validators } from '@angular/forms'; -import { Subject } from 'rxjs'; import { Router } from '@angular/router'; import { DialogComponent } from '@app/shared/components/dialog.component'; import { @@ -43,14 +42,18 @@ import { CellClickColumnInfo, defaultWidgetAction, WidgetActionSource, + WidgetActionType, + WidgetHeaderActionButtonType, + WidgetHeaderActionButtonTypes, + widgetHeaderActionButtonTypeTranslationMap, widgetType } from '@shared/models/widget.models'; -import { takeUntil } from 'rxjs/operators'; import { CustomActionEditorCompleter } from '@home/components/widget/lib/settings/common/action/custom-action.models'; import { WidgetService } from '@core/http/widget.service'; import { isDefinedAndNotNull, isNotEmptyStr } from '@core/utils'; import { MatSelect } from '@angular/material/select'; import { TranslateService } from '@ngx-translate/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; export interface WidgetActionDialogData { isAdd: boolean; @@ -58,6 +61,7 @@ export interface WidgetActionDialogData { actionsData: WidgetActionsData; action?: WidgetActionDescriptorInfo; widgetType: widgetType; + additionalWidgetActionTypes?: WidgetActionType[]; } @Component({ @@ -67,9 +71,7 @@ export interface WidgetActionDialogData { styleUrls: [] }) export class WidgetActionDialogComponent extends DialogComponent implements OnInit, OnDestroy, ErrorStateMatcher { - - private destroy$ = new Subject(); + WidgetActionDescriptorInfo> implements OnInit, ErrorStateMatcher { widgetActionFormGroup: FormGroup; @@ -85,6 +87,10 @@ export class WidgetActionDialogComponent extends DialogComponent = []; usedCellClickColumns: Array = []; + widgetHeaderActionButtonType = WidgetHeaderActionButtonType + widgetHeaderActionButtonTypes = WidgetHeaderActionButtonTypes; + widgetHeaderActionButtonTypeTranslationMap = widgetHeaderActionButtonTypeTranslationMap; + @ViewChild('columnIndexSelect') columnIndexSelect: MatSelect; columnIndexPlaceholderText = this.translate.instant('widget-config.select-column-index'); @@ -96,7 +102,8 @@ export class WidgetActionDialogComponent extends DialogComponent, public fb: FormBuilder, - private translate: TranslateService) { + private translate: TranslateService, + private destroyRef: DestroyRef) { super(store, router, dialogRef); this.isAdd = data.isAdd; if (this.isAdd) { @@ -120,14 +127,21 @@ export class WidgetActionDialogComponent extends DialogComponent { this.widgetActionFormGroup.get('name').updateValueAndValidity(); this.updateShowWidgetActionForm(); @@ -139,10 +153,13 @@ export class WidgetActionDialogComponent extends DialogComponent { this.updateShowWidgetActionForm(); }); + this.widgetActionFormGroup.get('buttonType').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => this.widgetHeaderButtonValidators()); setTimeout(() => { if (this.action?.actionSourceId === 'cellClick') { this.widgetActionFormGroup.get('columnIndex').enable(); @@ -154,10 +171,29 @@ export class WidgetActionDialogComponent extends DialogComponent + +
    alarm.filter
    diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/alarm/alarm-count-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/alarm/alarm-count-basic-config.component.ts index 20ceb9130a..de2959cb0d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/alarm/alarm-count-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/alarm/alarm-count-basic-config.component.ts @@ -69,6 +69,7 @@ export class AlarmCountBasicConfigComponent extends BasicWidgetConfigComponent { const settings: CountWidgetSettings = {...countDefaultSettings(true), ...(configData.config.settings || {})}; this.alarmCountWidgetConfigForm = this.fb.group({ alarmFilterConfig: [getAlarmFilterConfig(configData.config.datasources), []], + datasources: [configData.config.datasources, []], settings: [settings, []], @@ -81,6 +82,7 @@ export class AlarmCountBasicConfigComponent extends BasicWidgetConfigComponent { } protected prepareOutputConfig(config: any): WidgetConfigComponentData { + this.widgetConfig.config.datasources = config.datasources; setAlarmFilterConfig(config.alarmFilterConfig, this.widgetConfig.config.datasources); this.widgetConfig.config.settings = {...(this.widgetConfig.config.settings || {}), ...config.settings}; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts index 20722a4ea4..fb6cc40f29 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts @@ -149,6 +149,7 @@ import { import { ValueStepperBasicConfigComponent } from '@home/components/widget/config/basic/rpc/value-stepper-basic-config.component'; +import { MapBasicConfigComponent } from '@home/components/widget/config/basic/map/map-basic-config.component'; @NgModule({ declarations: [ @@ -199,7 +200,8 @@ import { LabelCardBasicConfigComponent, LabelValueCardBasicConfigComponent, UnreadNotificationBasicConfigComponent, - ScadaSymbolBasicConfigComponent + ScadaSymbolBasicConfigComponent, + MapBasicConfigComponent ], imports: [ CommonModule, @@ -252,7 +254,8 @@ import { MobileAppQrCodeBasicConfigComponent, LabelCardBasicConfigComponent, LabelValueCardBasicConfigComponent, - UnreadNotificationBasicConfigComponent + UnreadNotificationBasicConfigComponent, + MapBasicConfigComponent ] }) export class BasicWidgetConfigModule { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts index 9c75a2fcd1..d790b1d477 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts @@ -45,7 +45,7 @@ import { TruncatePipe } from '@shared/pipe/truncate.pipe'; import { DataKeyConfigDialogComponent, DataKeyConfigDialogData -} from '@home/components/widget/config/data-key-config-dialog.component'; +} from '@home/components/widget/lib/settings/common/key/data-key-config-dialog.component'; import { deepClone, formatValue } from '@core/utils'; import { AggregatedValueCardKeyPosition, @@ -59,7 +59,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-aggregated-data-key-row', templateUrl: './aggregated-data-key-row.component.html', - styleUrls: ['./aggregated-data-key-row.component.scss', '../../data-keys.component.scss'], + styleUrls: ['./aggregated-data-key-row.component.scss', '../../../lib/settings/common/key/data-keys.component.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts index df2ba6ef9f..f8e22d54dc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts @@ -38,7 +38,7 @@ import { WidgetConfigComponent } from '@home/components/widget/widget-config.com import { DataKey, DatasourceType, widgetType } from '@shared/models/widget.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { UtilsService } from '@core/services/utils.service'; -import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { aggregatedValueCardDefaultKeySettings } from '@home/components/widget/lib/cards/aggregated-value-card.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-key-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-key-row.component.ts index 7a06ef38f9..686f868423 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-key-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-key-row.component.ts @@ -34,7 +34,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-comparison-key-row', templateUrl: './comparison-key-row.component.html', - styleUrls: ['./comparison-key-row.component.scss', '../../data-keys.component.scss'], + styleUrls: ['./comparison-key-row.component.scss', '../../../lib/settings/common/key/data-keys.component.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts index 2fd004f0fd..7997e332d5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts @@ -41,12 +41,12 @@ import { MatDialog } from '@angular/material/dialog'; import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; import { DataKey, DataKeyConfigMode, DatasourceType, Widget, widgetType } from '@shared/models/widget.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; -import { DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeySettingsFunction } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { merge } from 'rxjs'; import { DataKeyConfigDialogComponent, DataKeyConfigDialogData -} from '@home/components/widget/config/data-key-config-dialog.component'; +} from '@home/components/widget/lib/settings/common/key/data-key-config-dialog.component'; import { deepClone } from '@core/utils'; import { Dashboard } from '@shared/models/dashboard.models'; import { IAliasController } from '@core/api/widget-api.models'; @@ -78,7 +78,7 @@ export const dataKeyRowValidator = (control: AbstractControl): ValidationErrors @Component({ selector: 'tb-data-key-row', templateUrl: './data-key-row.component.html', - styleUrls: ['./data-key-row.component.scss', '../../data-keys.component.scss'], + styleUrls: ['./data-key-row.component.scss', '../../../lib/settings/common/key/data-keys.component.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.ts index 89a2173ba0..fbd149571e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.ts @@ -43,7 +43,7 @@ import { dataKeyRowValidator, dataKeyValid } from '@home/components/widget/confi import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { UtilsService } from '@core/services/utils.service'; -import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { coerceBoolean } from '@shared/decorators/coercion'; import { TimeSeriesChartYAxisId } from '@home/components/widget/lib/chart/time-series-chart.models'; import { FormProperty } from '@shared/models/dynamic-form.models'; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts index 438808fb37..353ca04cb4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts @@ -122,7 +122,8 @@ export class WidgetActionsPanelComponent implements ControlValueAccessor, OnInit widgetTitle: this.widgetConfigComponent.modelValue.widgetName, callbacks: this.widgetConfigComponent.widgetConfigCallbacks, actionsData, - widgetType: this.widgetConfigComponent.widgetType + widgetType: this.widgetConfigComponent.widgetType, + additionalWidgetActionTypes: this.widgetConfigComponent.modelValue.typeParameters.additionalWidgetActionTypes } }).afterClosed().subscribe( (res) => { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html new file mode 100644 index 0000000000..dcefd50801 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html @@ -0,0 +1,100 @@ + + + + + + +
    +
    widget-config.appearance
    +
    + + {{ 'widget-config.title' | translate }} + +
    + + + + + + + +
    +
    +
    + + {{ 'widget-config.card-icon' | translate }} + +
    + + + + + + + + +
    +
    +
    +
    +
    widget-config.card-appearance
    +
    +
    {{ 'widgets.background.background' | translate }}
    + + +
    +
    +
    widget-config.show-card-buttons
    + + {{ 'fullscreen.fullscreen' | translate }} + +
    +
    +
    {{ 'widget-config.card-border-radius' | translate }}
    + + + +
    +
    +
    {{ 'widget-config.card-padding' | translate }}
    + + + +
    +
    + + +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.ts new file mode 100644 index 0000000000..9ce53ded4f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.ts @@ -0,0 +1,187 @@ +/// +/// 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, Injector } from '@angular/core'; +import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { WidgetConfigComponentData } from '@home/models/widget-component.models'; +import { isDefinedAndNotNull, isUndefined, mergeDeep, mergeDeepIgnoreArray } from '@core/utils'; +import { mapWidgetDefaultSettings, MapWidgetSettings } from '@home/components/widget/lib/maps/map-widget.models'; +import { cssSizeToStrSize, resolveCssSize } from '@shared/models/widget-settings.models'; +import { WidgetConfig } from '@shared/models/widget.models'; +import { + getTimewindowConfig, + setTimewindowConfig +} from '@home/components/widget/config/timewindow-config-panel.component'; + +@Component({ + selector: 'tb-map-basic-config', + templateUrl: './map-basic-config.component.html', + styleUrls: ['../basic-config.scss'] +}) +export class MapBasicConfigComponent extends BasicWidgetConfigComponent { + + mapWidgetConfigForm: UntypedFormGroup; + + trip = false; + + constructor(protected store: Store, + protected widgetConfigComponent: WidgetConfigComponent, + private $injector: Injector, + private fb: UntypedFormBuilder) { + super(store, widgetConfigComponent); + } + + protected configForm(): UntypedFormGroup { + return this.mapWidgetConfigForm; + } + + protected setupConfig(widgetConfig: WidgetConfigComponentData) { + const params = widgetConfig.typeParameters as any; + if (isDefinedAndNotNull(params.trip)) { + this.trip = params.trip === true; + } + super.setupConfig(widgetConfig); + } + + protected setupDefaults(configData: WidgetConfigComponentData) { + const settings = configData.config.settings as MapWidgetSettings; + if (settings?.markers?.length) { + settings.markers = []; + } + if (settings?.polygons?.length) { + settings.polygons = []; + } + if (settings?.circles?.length) { + settings.circles = []; + } + if (this.trip) { + if (settings?.trips?.length) { + settings.trips = []; + } + } + } + + protected onConfigSet(configData: WidgetConfigComponentData) { + const settings: MapWidgetSettings = mergeDeepIgnoreArray({} as MapWidgetSettings, + mapWidgetDefaultSettings, configData.config.settings as MapWidgetSettings); + const iconSize = resolveCssSize(configData.config.iconSize); + this.mapWidgetConfigForm = this.fb.group({ + mapSettings: [settings, []], + + showTitle: [configData.config.showTitle, []], + title: [configData.config.title, []], + titleFont: [configData.config.titleFont, []], + titleColor: [configData.config.titleColor, []], + + showIcon: [configData.config.showTitleIcon, []], + iconSize: [iconSize[0], [Validators.min(0)]], + iconSizeUnit: [iconSize[1], []], + icon: [configData.config.titleIcon, []], + iconColor: [configData.config.iconColor, []], + + background: [settings.background, []], + + cardButtons: [this.getCardButtons(configData.config), []], + borderRadius: [configData.config.borderRadius, []], + padding: [settings.padding, []], + + actions: [configData.config.actions || {}, []] + }); + if (this.trip) { + this.mapWidgetConfigForm.addControl('timewindowConfig', this.fb.control(getTimewindowConfig(configData.config))) + } + } + + protected prepareOutputConfig(config: any): WidgetConfigComponentData { + if (this.trip) { + setTimewindowConfig(this.widgetConfig.config, config.timewindowConfig); + } + this.widgetConfig.config.settings = config.mapSettings || {}; + + this.widgetConfig.config.showTitle = config.showTitle; + this.widgetConfig.config.title = config.title; + this.widgetConfig.config.titleFont = config.titleFont; + this.widgetConfig.config.titleColor = config.titleColor; + + this.widgetConfig.config.showTitleIcon = config.showIcon; + this.widgetConfig.config.iconSize = cssSizeToStrSize(config.iconSize, config.iconSizeUnit); + this.widgetConfig.config.titleIcon = config.icon; + this.widgetConfig.config.iconColor = config.iconColor; + + this.widgetConfig.config.settings.background = config.background; + + this.setCardButtons(config.cardButtons, this.widgetConfig.config); + this.widgetConfig.config.borderRadius = config.borderRadius; + this.widgetConfig.config.settings.padding = config.padding; + + this.widgetConfig.config.actions = config.actions; + + return this.widgetConfig; + } + + protected validatorTriggers(): string[] { + return ['showTitle', 'showIcon']; + } + + protected updateValidators(emitEvent: boolean, trigger?: string) { + const showTitle: boolean = this.mapWidgetConfigForm.get('showTitle').value; + const showIcon: boolean = this.mapWidgetConfigForm.get('showIcon').value; + + if (showTitle) { + this.mapWidgetConfigForm.get('title').enable(); + this.mapWidgetConfigForm.get('titleFont').enable(); + this.mapWidgetConfigForm.get('titleColor').enable(); + this.mapWidgetConfigForm.get('showIcon').enable({emitEvent: false}); + if (showIcon) { + this.mapWidgetConfigForm.get('iconSize').enable(); + this.mapWidgetConfigForm.get('iconSizeUnit').enable(); + this.mapWidgetConfigForm.get('icon').enable(); + this.mapWidgetConfigForm.get('iconColor').enable(); + } else { + this.mapWidgetConfigForm.get('iconSize').disable(); + this.mapWidgetConfigForm.get('iconSizeUnit').disable(); + this.mapWidgetConfigForm.get('icon').disable(); + this.mapWidgetConfigForm.get('iconColor').disable(); + } + } else { + this.mapWidgetConfigForm.get('title').disable(); + this.mapWidgetConfigForm.get('titleFont').disable(); + this.mapWidgetConfigForm.get('titleColor').disable(); + this.mapWidgetConfigForm.get('showIcon').disable({emitEvent: false}); + this.mapWidgetConfigForm.get('iconSize').disable(); + this.mapWidgetConfigForm.get('iconSizeUnit').disable(); + this.mapWidgetConfigForm.get('icon').disable(); + this.mapWidgetConfigForm.get('iconColor').disable(); + } + } + + private getCardButtons(config: WidgetConfig): string[] { + const buttons: string[] = []; + if (isUndefined(config.enableFullscreen) || config.enableFullscreen) { + buttons.push('fullscreen'); + } + return buttons; + } + + private setCardButtons(buttons: string[], config: WidgetConfig) { + config.enableFullscreen = buttons.includes('fullscreen'); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html index 297f0c301d..f7bb0662e9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html @@ -36,7 +36,7 @@ datasourceFormGroup.get('type').value === datasourceType.entity || datasourceFormGroup.get('type').value === datasourceType.entityCount || datasourceFormGroup.get('type').value === datasourceType.alarmCount ? datasourceFormGroup.get('type').value : ''"> - @@ -66,6 +66,10 @@
    (); widgetConfigChanged = this.widgetConfigChangedEmitter.asObservable(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.html index 19a0a95abf..acd4fc43be 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.html @@ -78,7 +78,7 @@ - + {{ column.title }} 0) { this.defaultPageSize = pageSize; } - this.pageSizeOptions = [this.defaultPageSize, this.defaultPageSize * 2, this.defaultPageSize * 3]; + + if (!this.defaultPageSize) { + this.defaultPageSize = pageStepIncrement ?? 10; + } + + if (!isDefinedAndNotNull(pageStepIncrement) || !isDefinedAndNotNull(pageStepCount)) { + pageStepIncrement = this.defaultPageSize; + pageStepCount = 3; + } + + for (let i = 1; i <= pageStepCount; i++) { + this.pageSizeOptions.push(pageStepIncrement * i); + } this.pageLink.pageSize = this.displayPagination ? this.defaultPageSize : 1024; const alarmFilter = this.entityService.resolveAlarmFilter(this.widgetConfig.alarmFilterConfig, false); @@ -429,6 +444,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, dataKey.label = this.utils.customTranslation(dataKey.label, dataKey.label); dataKey.title = getHeaderTitle(dataKey, keySettings, this.utils); dataKey.def = 'def' + this.columns.length; + dataKey.sortable = !keySettings.disableSorting && !(dataKey.type === DataKeyType.alarm && dataKey.name.startsWith('details.')); if (dataKey.type === DataKeyType.alarm && !isDefined(keySettings.columnWidth)) { const alarmField = alarmFields[dataKey.name]; if (alarmField && alarmField.time) { @@ -1146,10 +1162,6 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, } } - isSorting(column: EntityColumn): boolean { - return column.type === DataKeyType.alarm && column.name.startsWith('details.'); - } - private clearCache() { this.cellContentCache.length = 0; this.cellStyleCache.length = 0; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts index 646569c405..9086bb1896 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts @@ -71,7 +71,7 @@ import { BehaviorSubject } from 'rxjs'; import { AggregationType } from '@shared/models/time/time.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { WidgetSubscriptionOptions } from '@core/api/widget-api.models'; -import { DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeySettingsFunction } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { DeepPartial } from '@shared/models/common'; import { BarRenderSharedContext } from '@home/components/widget/lib/chart/time-series-chart-bar.models'; import { TimeSeriesChartStateValueConverter } from '@home/components/widget/lib/chart/time-series-chart-state.models'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts index 85b4775b97..c395192a60 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts @@ -42,7 +42,7 @@ import { import { IWidgetSubscription } from '@core/api/widget-api.models'; import { UtilsService } from '@core/services/utils.service'; import { TranslateService } from '@ngx-translate/core'; -import { deepClone, hashCode, isDefined, isNumber, isObject, isUndefined } from '@core/utils'; +import { deepClone, hashCode, isDefined, isDefinedAndNotNull, isNumber, isObject, isUndefined } from '@core/utils'; import cssjs from '@core/css/css'; import { CollectionViewer, DataSource } from '@angular/cdk/collections'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; @@ -139,7 +139,7 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni public enableStickyHeader = true; public enableStickyAction = true; public showCellActionsMenu = true; - public pageSizeOptions; + public pageSizeOptions = []; public pageLink: EntityDataPageLink; public sortOrderProperty: string; public textSearchMode = false; @@ -161,7 +161,7 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni private widgetResize$: ResizeObserver; private destroy$ = new Subject(); - private defaultPageSize = 10; + private defaultPageSize; private defaultSortOrder = 'entityName'; private contentsInfo: {[key: string]: CellContentInfo} = {}; @@ -311,10 +311,25 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'entity, ctx'); const pageSize = this.settings.defaultPageSize; + let pageStepIncrement = this.settings.pageStepIncrement; + let pageStepCount = this.settings.pageStepCount; + if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { this.defaultPageSize = pageSize; } - this.pageSizeOptions = [this.defaultPageSize, this.defaultPageSize * 2, this.defaultPageSize * 3]; + + if (!this.defaultPageSize) { + this.defaultPageSize = pageStepIncrement ?? 10; + } + + if (!isDefinedAndNotNull(pageStepIncrement) || !isDefinedAndNotNull(pageStepCount)) { + pageStepIncrement = this.defaultPageSize; + pageStepCount = 3; + } + + for (let i = 1; i <= pageStepCount; i++) { + this.pageSizeOptions.push(pageStepIncrement * i); + } this.pageLink.pageSize = this.displayPagination ? this.defaultPageSize : 1024; this.noDataDisplayMessageText = @@ -448,7 +463,8 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni dataKey.label = this.utils.customTranslation(dataKey.label, dataKey.label); dataKey.title = getHeaderTitle(dataKey, keySettings, this.utils); dataKey.def = 'def' + this.columns.length; - dataKey.sortable = !dataKey.usePostProcessing && (!dataKey.aggregationType || dataKey.aggregationType === AggregationType.NONE); + dataKey.sortable = !keySettings.disableSorting && !dataKey.usePostProcessing + && (!dataKey.aggregationType ||dataKey.aggregationType === AggregationType.NONE); if (dataKey.type === DataKeyType.entityField && !isDefined(keySettings.columnWidth) || keySettings.columnWidth === '0px') { const entityField = entityFields[dataKey.name]; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/circle.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/circle.ts similarity index 96% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/circle.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/circle.ts index 9b55040daa..dbba0ada83 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/circle.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/circle.ts @@ -15,10 +15,10 @@ /// import L, { LeafletMouseEvent } from 'leaflet'; -import { CircleData, WidgetCircleSettings } from '@home/components/widget/lib/maps/map-models'; -import { functionValueCalculator, parseWithTranslation } from '@home/components/widget/lib/maps/common-maps-utils'; -import LeafletMap from '@home/components/widget/lib/maps/leaflet-map'; -import { createTooltip } from '@home/components/widget/lib/maps/maps-utils'; +import { CircleData, WidgetCircleSettings } from '@home/components/widget/lib/maps-legacy/map-models'; +import { functionValueCalculator, parseWithTranslation } from '@home/components/widget/lib/maps-legacy/common-maps-utils'; +import LeafletMap from '@home/components/widget/lib/maps-legacy/leaflet-map'; +import { createTooltip } from '@home/components/widget/lib/maps-legacy/maps-utils'; import { FormattedData } from '@shared/models/widget.models'; import { fillDataPattern, processDataPattern, safeExecuteTbFunction } from '@core/utils'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/common-maps-utils.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/common-maps-utils.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.html diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.scss rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.scss diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts similarity index 99% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts index ef704abc7e..efd473e857 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts @@ -16,9 +16,7 @@ import L, { FeatureGroup, LatLngBounds, LatLngTuple, PointExpression, Projection } from 'leaflet'; import tinycolor from 'tinycolor2'; -import 'leaflet-providers'; -import 'leaflet.markercluster'; -import '@geoman-io/leaflet-geoman-free'; +import '@home/components/widget/lib/maps/leaflet/leaflet-tb'; import { CircleData, @@ -43,8 +41,8 @@ import { isJSON, isValidLatLng, LabelSettings -} from '@home/components/widget/lib/maps/maps-utils'; -import { checkLngLat, createLoadingDiv } from '@home/components/widget/lib/maps/common-maps-utils'; +} from '@home/components/widget/lib/maps-legacy/maps-utils'; +import { checkLngLat, createLoadingDiv } from '@home/components/widget/lib/maps-legacy/common-maps-utils'; import { WidgetContext } from '@home/models/widget-component.models'; import { deepClone, @@ -60,7 +58,7 @@ import { TranslateService } from '@ngx-translate/core'; import { SelectEntityDialogComponent, SelectEntityDialogData -} from '@home/components/widget/lib/maps/dialogs/select-entity-dialog.component'; +} from '@home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component'; import { MatDialog } from '@angular/material/dialog'; import { FormattedData, ReplaceInfo } from '@shared/models/widget.models'; import { ImagePipe } from '@shared/pipe/image.pipe'; @@ -125,10 +123,6 @@ export default abstract class LeafletMap { private initMarkerClusterSettings() { const markerClusteringSettings: WidgetMarkerClusteringSettings = this.options; if (markerClusteringSettings.useClusterMarkers) { - // disabled marker cluster icon - (L as any).MarkerCluster = (L as any).MarkerCluster.extend({ - options: { pmIgnore: true, ...L.Icon.prototype.options } - }); this.clusteringSettings = { spiderfyOnMaxZoom: markerClusteringSettings.spiderfyOnMaxZoom, zoomToBoundsOnClick: markerClusteringSettings.zoomOnClick, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-models.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/map-models.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-models.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.interface.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-widget.interface.ts similarity index 91% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.interface.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-widget.interface.ts index 8c8a192570..fd0629444a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.interface.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-widget.interface.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import LeafletMap from '@home/components/widget/lib/maps/leaflet-map'; +import LeafletMap from '@home/components/widget/lib/maps-legacy/leaflet-map'; export interface MapWidgetInterface { map?: LeafletMap; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget2.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-widget2.ts similarity index 99% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget2.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-widget2.ts index 887956f212..c0a081fe2b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget2.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-widget2.ts @@ -23,7 +23,7 @@ import { Datasource, DatasourceData, FormattedData, WidgetActionDescriptor } fro import { TranslateService } from '@ngx-translate/core'; import { UtilsService } from '@core/services/utils.service'; import { EntityDataPageLink } from '@shared/models/query/query.models'; -import { providerClass } from '@home/components/widget/lib/maps/providers/public-api'; +import { providerClass } from '@home/components/widget/lib/maps-legacy/providers/public-api'; import { isDefined, isDefinedAndNotNull, parseTbFunction } from '@core/utils'; import L from 'leaflet'; import { firstValueFrom, forkJoin, from, Observable, of } from 'rxjs'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/maps-utils.ts similarity index 99% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/maps-utils.ts index 49abdceaf3..1722442fc7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/maps-utils.ts @@ -18,7 +18,7 @@ import L from 'leaflet'; import { GenericFunction, ShowTooltipAction, WidgetToolipSettings } from './map-models'; import { Datasource, FormattedData } from '@app/shared/models/widget.models'; import { fillDataPattern, isDefinedAndNotNull, isString, processDataPattern, safeExecuteTbFunction } from '@core/utils'; -import { parseWithTranslation } from '@home/components/widget/lib/maps/common-maps-utils'; +import { parseWithTranslation } from '@home/components/widget/lib/maps-legacy/common-maps-utils'; import { CompiledTbFunction } from '@shared/models/js-function.models'; export function createTooltip(target: L.Layer, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.scss rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.scss diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.ts similarity index 99% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.ts index 15cf6a97b0..e300939b37 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.ts @@ -52,7 +52,7 @@ export class Marker { this.leafletMarker = L.marker(this.location, { pmIgnore: !settings.draggableMarker, snapIgnore: !snappable, - tbMarkerData: this.data + tbMarkerData: this.data as any }); this.markerOffset = [ @@ -101,7 +101,7 @@ export class Marker { setDataSources(data: FormattedData, dataSources: FormattedData[]) { this.data = data; this.dataSources = dataSources; - this.leafletMarker.options.tbMarkerData = data; + this.leafletMarker.options.tbMarkerData = data as any; } updateMarkerTooltip(data: FormattedData) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/polygon.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/polygon.ts similarity index 99% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/polygon.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/polygon.ts index 9304a815d1..c3170cd008 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/polygon.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/polygon.ts @@ -20,7 +20,7 @@ import { functionValueCalculator, parseWithTranslation } from './common-maps-uti import { WidgetPolygonSettings } from './map-models'; import { FormattedData } from '@shared/models/widget.models'; import { fillDataPattern, processDataPattern, safeExecuteTbFunction } from '@core/utils'; -import LeafletMap from '@home/components/widget/lib/maps/leaflet-map'; +import LeafletMap from '@home/components/widget/lib/maps-legacy/leaflet-map'; export class Polygon { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/polyline.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/polyline.ts similarity index 98% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/polyline.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/polyline.ts index ccec4568d5..2421247242 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/polyline.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/polyline.ts @@ -19,7 +19,7 @@ import 'leaflet-polylinedecorator'; import L, { PolylineDecorator, PolylineDecoratorOptions, Symbol } from 'leaflet'; import { WidgetPolylineSettings } from './map-models'; -import { functionValueCalculator } from '@home/components/widget/lib/maps/common-maps-utils'; +import { functionValueCalculator } from '@home/components/widget/lib/maps-legacy/common-maps-utils'; import { FormattedData } from '@shared/models/widget.models'; export class Polyline { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/google-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/google-map.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/google-map.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/google-map.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/here-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/here-map.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/here-map.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/here-map.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/image-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/image-map.ts similarity index 97% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/image-map.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/image-map.ts index b2d2522beb..51bfdd059d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/image-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/image-map.ts @@ -25,7 +25,7 @@ import { } from '../map-models'; import { combineLatest, Observable, of, ReplaySubject, switchMap } from 'rxjs'; import { catchError } from 'rxjs/operators'; -import { calculateNewPointCoordinate, loadImageWithAspect } from '@home/components/widget/lib/maps/common-maps-utils'; +import { calculateNewPointCoordinate, loadImageWithAspect } from '@home/components/widget/lib/maps-legacy/common-maps-utils'; import { WidgetContext } from '@home/models/widget-component.models'; import { DataSet, DatasourceType, FormattedData, widgetType } from '@shared/models/widget.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; @@ -127,32 +127,33 @@ export class ImageMap extends LeafletMap { return this.imageFromAlias(result); } - private imageFromUrl(url: string): Observable { + private imageFromUrl(url: string, update = false): Observable { return loadImageWithAspect(this.ctx.$injector.get(ImagePipe), url).pipe( switchMap( aspectImage => { if (aspectImage) { return of({ imageUrl: aspectImage.url, aspect: aspectImage.aspect, - update: false + update }); } else { - return this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl); + return this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl, update); } } ), - catchError(() => this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl)) + catchError(() => this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl, update)) ); } private imageFromAlias(alias: Observable<[DataSet, boolean]>): Observable { return alias.pipe( switchMap(res => { + const update = res[1]; const url = res[0][0][1]; const mapImage: MapImage = { imageUrl: null, aspect: null, - update: res[1] + update }; return loadImageWithAspect(this.ctx.$injector.get(ImagePipe), url).pipe( switchMap((aspectImage) => { @@ -161,10 +162,10 @@ export class ImageMap extends LeafletMap { mapImage.imageUrl = aspectImage.url; return of(mapImage); } else { - return this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl); + return this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl, update); } }), - catchError(() => this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl)) + catchError(() => this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl, update)) ); }) ); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/openstreet-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/openstreet-map.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/openstreet-map.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/openstreet-map.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/public-api.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/public-api.ts similarity index 93% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/public-api.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/public-api.ts index 8010597f59..c45730733d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/public-api.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/public-api.ts @@ -20,7 +20,7 @@ import { GoogleMap } from './google-map'; import { HEREMap } from './here-map'; import { ImageMap } from './image-map'; import { Type } from '@angular/core'; -import LeafletMap from '@home/components/widget/lib/maps/leaflet-map'; +import LeafletMap from '@home/components/widget/lib/maps-legacy/leaflet-map'; export const providerClass: { [key: string]: Type } = { 'openstreet-map': OpenStreetMap, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/tencent-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/tencent-map.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/tencent-map.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/tencent-map.ts 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 new file mode 100644 index 0000000000..9e25ddb486 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts @@ -0,0 +1,237 @@ +/// +/// 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 { + CirclesDataLayerSettings, + defaultBaseCirclesDataLayerSettings, + isJSON, MapDataLayerType, + TbCircleData, + TbMapDatasource +} from '@shared/models/widget/maps/map.models'; +import L from 'leaflet'; +import { FormattedData } from '@shared/models/widget.models'; +import { 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'; +import { + TbLatestDataLayerItem, + UnplacedMapDataItem +} from '@home/components/widget/lib/maps/data-layer/latest-map-data-layer'; +import { map } from 'rxjs/operators'; + +class TbCircleDataLayerItem extends TbLatestDataLayerItem { + + private circle: L.Circle; + private circleStyle: L.PathOptions; + private editing = false; + + constructor(data: FormattedData, + dsData: FormattedData[], + protected settings: CirclesDataLayerSettings, + protected dataLayer: TbCirclesDataLayer) { + super(data, dsData, settings, dataLayer); + } + + public isEditing() { + return this.editing; + } + + public updateBubblingMouseEvents() { + this.circle.options.bubblingMouseEvents = !this.dataLayer.isEditMode(); + } + + protected create(data: FormattedData, 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.updateLabel(data, dsData); + return this.circle; + } + + protected unbindLabel() { + this.circle.unbindTooltip(); + } + + protected bindLabel(content: L.Content): void { + this.circle.bindTooltip(content, { className: 'tb-circle-label', permanent: true, direction: 'center'}) + .openTooltip(this.circle.getLatLng()); + } + + protected doUpdate(data: FormattedData, 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); + } + + protected doInvalidateCoordinates(data: FormattedData, _dsData: FormattedData[]): void { + this.updateCircleShape(data); + } + + protected addItemClass(clazz: string): void { + if ((this.circle as any)._path) { + L.DomUtil.addClass((this.circle as any)._path, clazz); + } + } + + protected removeItemClass(clazz: string): void { + if ((this.circle as any)._path) { + L.DomUtil.removeClass((this.circle as any)._path, clazz); + } + } + + protected enableDrag(): void { + this.circle.pm.setOptions({ + snappable: this.dataLayer.isSnappable() + }); + this.circle.pm.enableLayerDrag(); + this.circle.on('pm:dragstart', () => { + this.editing = true; + }); + this.circle.on('pm:dragend', () => { + this.saveCircleCoordinates(); + this.editing = false; + }); + } + + protected disableDrag(): void { + this.circle.pm.disableLayerDrag(); + this.circle.off('pm:dragstart'); + this.circle.off('pm:dragend'); + } + + protected onSelected(): L.TB.ToolbarButtonOptions[] { + if (this.dataLayer.isEditEnabled()) { + 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(); + } + return []; + } + + protected onDeselected(): void { + if (this.dataLayer.isEditEnabled()) { + this.circle.pm.disable(); + this.circle.off('pm:markerdragstart'); + this.circle.off('pm:markerdragend'); + this.circle.off('pm:edit'); + } + } + + protected removeDataItemTitle(): string { + return this.dataLayer.getCtx().translate.instant('widgets.maps.data-layer.circle.remove-circle-for', {entityName: this.data.entityName}); + } + + protected removeDataItem(): Observable { + return this.dataLayer.saveCircleCoordinates(this.data, null, null); + } + + private saveCircleCoordinates() { + const center = this.circle.getLatLng(); + const radius = this.circle.getRadius(); + this.dataLayer.saveCircleCoordinates(this.data, center, radius).subscribe(); + } + + private updateCircleShape(data: FormattedData) { + if (this.editing) { + return; + } + const circleData = this.dataLayer.extractCircleCoordinates(data); + const center = new L.LatLng(circleData.latitude, circleData.longitude); + if (!this.circle.getLatLng().equals(center)) { + this.circle.setLatLng(center); + } + if (this.circle.getRadius() !== circleData.radius) { + this.circle.setRadius(circleData.radius); + } + } +} + +export class TbCirclesDataLayer extends TbShapesDataLayer { + + constructor(protected map: TbMap, + inputSettings: CirclesDataLayerSettings) { + super(map, inputSettings); + } + + public dataLayerType(): MapDataLayerType { + return 'circles'; + } + + public placeItem(item: UnplacedMapDataItem, layer: L.Layer): void { + if (layer instanceof L.Circle) { + const center = layer.getLatLng(); + const radius = layer.getRadius(); + this.saveCircleCoordinates(item.entity, center, radius).subscribe( + (converted) => { + item.entity[this.settings.circleKey.label] = JSON.stringify(converted); + this.createItemFromUnplaced(item); + } + ); + } else { + console.warn('Unable to place item, layer is not a circle.'); + } + } + + public extractCircleCoordinates(data: FormattedData) { + const circleData: TbCircleData = JSON.parse(data[this.settings.circleKey.label]); + return this.map.circleDataToCoordinates(circleData); + } + + public saveCircleCoordinates(data: FormattedData, center: L.LatLng, radius: number): Observable { + const converted = center ? this.map.coordinatesToCircleData(center, radius) : null; + const circleData = [ + { + dataKey: this.settings.circleKey, + value: converted + } + ]; + return this.map.saveItemData(data.$datasource, circleData, this.settings.edit?.attributeScope).pipe( + map(() => converted) + ); + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + datasource.dataKeys.push(this.settings.circleKey); + return datasource; + } + + protected defaultBaseSettings(map: TbMap): Partial { + return defaultBaseCirclesDataLayerSettings(map.type()); + } + + protected doSetup(): Observable { + return super.doSetup(); + } + + protected isValidLayerData(layerData: FormattedData): boolean { + return layerData && isNotEmptyStr(layerData[this.settings.circleKey.label]) && isJSON(layerData[this.settings.circleKey.label]); + } + + protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbLatestDataLayerItem { + return new TbCircleDataLayerItem(data, dsData, this.settings, this); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts new file mode 100644 index 0000000000..d5329586be --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.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 { + DataLayerTooltipSettings, + DataLayerTooltipTrigger, processTooltipTemplate, + TbMapDatasource +} from '@shared/models/widget/maps/map.models'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { FormattedData } from '@shared/models/widget.models'; +import L from 'leaflet'; +import { DataLayerPatternProcessor } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; + +export const createTooltip = (map: TbMap, + layer: L.Layer, + settings: DataLayerTooltipSettings, + data: FormattedData, + canOpen: () => boolean): L.Popup => { + const tooltip = L.popup(); + layer.bindPopup(tooltip, {autoClose: settings.autoclose, closeOnClick: false}); + layer.off('click'); + if (settings.trigger === DataLayerTooltipTrigger.click) { + layer.on('click', () => { + if (tooltip.isOpen()) { + layer.closePopup(); + } else if (canOpen()) { + layer.openPopup(); + } + }); + } else if (settings.trigger === DataLayerTooltipTrigger.hover) { + layer.on('mouseover', () => { + if (canOpen()) { + layer.openPopup(); + } + }); + layer.on('mousemove', (e) => { + tooltip.setLatLng(e.latlng); + }); + layer.on('mouseout', () => { + layer.closePopup(); + }); + } + layer.on('popupopen', () => { + bindTooltipActions(map, tooltip, settings, data); + (layer as any)._popup._closeButton.addEventListener('click', (event: Event) => { + event.preventDefault(); + }); + }); + return tooltip; +} + +export const updateTooltip = (map: TbMap, + tooltip: L.Popup, + settings: DataLayerTooltipSettings, + processor: DataLayerPatternProcessor, + data: FormattedData, + dsData: FormattedData[]): void => { + let tooltipTemplate = processor.processPattern(data, dsData); + tooltipTemplate = processTooltipTemplate(tooltipTemplate); + tooltip.setContent(tooltipTemplate); + if (tooltip.isOpen() && tooltip.getElement()) { + bindTooltipActions(map, tooltip, settings, data); + } +} + +const bindTooltipActions = (map: TbMap, tooltip: L.Popup, settings: DataLayerTooltipSettings, data: FormattedData): void => { + const actions = tooltip.getElement().getElementsByClassName('tb-custom-action'); + Array.from(actions).forEach( + (element: HTMLElement) => { + const actionName = element.getAttribute('data-action-name'); + if (settings?.tagActions) { + const action = settings.tagActions.find(action => action.name === actionName); + if (action) { + element.onclick = ($event) => + { + map.dataItemClick($event, action, data); + return false; + }; + } + } + } + ); +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/latest-map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/latest-map-data-layer.ts new file mode 100644 index 0000000000..3a537ba8e7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/latest-map-data-layer.ts @@ -0,0 +1,439 @@ +/// +/// 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 { + DataLayerEditAction, + MapDataLayerSettings, + TbMapDatasource +} from '@shared/models/widget/maps/map.models'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { FormattedData, WidgetActionType } from '@shared/models/widget.models'; +import { Observable } from 'rxjs'; +import L from 'leaflet'; +import { createTooltip, updateTooltip } from './data-layer-utils'; +import { TbDataLayerItem, TbMapDataLayer } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; + +export abstract class TbLatestDataLayerItem = TbLatestMapDataLayer, L extends L.Layer = L.Layer> extends TbDataLayerItem { + + protected tooltip: L.Popup; + protected data: FormattedData; + protected selected = false; + + protected constructor(data: FormattedData, + dsData: FormattedData[], + settings: S, + dataLayer: D) { + super(settings, dataLayer); + this.data = data; + this.layer = this.create(data, dsData); + if (this.settings.tooltip?.show) { + this.tooltip = createTooltip(this.dataLayer.getMap(), + this.layer, this.settings.tooltip, this.data, () => { + return !this.isEditing(); + }); + updateTooltip(this.dataLayer.getMap(), this.tooltip, + this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, data, dsData); + } + this.bindEvents(); + try { + this.dataLayer.getDataLayerContainer().addLayer(this.layer); + this.editModeUpdated(); + } catch (e) { + console.warn(e); + } + } + + public invalidateCoordinates(): void { + this.doInvalidateCoordinates(this.data, this.dataLayer.getMap().getData()); + } + + public select(): L.TB.ToolbarButtonOptions[] { + if (!this.selected) { + this.selected = true; + this.disableEdit(); + this.updateSelectedState(); + const buttons = this.onSelected(); + if (this.dataLayer.isRemoveEnabled()) { + buttons.push({ + id: 'remove', + title: this.removeDataItemTitle(), + click: () => { + this.removeDataItem().subscribe( + () => this.dataLayer.removeItem(this.data.entityId) + ); + }, + iconClass: 'tb-remove' + }); + } + return buttons; + } else { + return []; + } + } + + public deselect(cancel = false, force = false): boolean { + if (this.selected) { + if (this.canDeselect(cancel) || force) { + this.selected = false; + this.layer.closePopup(); + this.updateSelectedState(); + this.onDeselected(); + this.editModeUpdated(); + } else { + return false; + } + } + return true; + } + + public isSelected() { + return this.selected; + } + + public editModeUpdated() { + if (this.dataLayer.isEditMode() && !this.selected) { + this.enableEdit(); + } else { + this.disableEdit(); + } + this.updateSelectedState(); + this.updateBubblingMouseEvents(); + } + + public dragModeUpdated() { + if (this.dataLayer.isEditMode() && !this.selected) { + if (this.dataLayer.allowDrag()) { + this.enableDrag(); + this.addItemClass('tb-draggable'); + } else { + this.disableDrag(); + this.removeItemClass('tb-draggable'); + } + } + } + + public update(data: FormattedData, dsData: FormattedData[]): void { + this.data = data; + this.doUpdate(data, dsData); + } + + public remove() { + if (this.selected) { + this.dataLayer.getMap().deselectItem(false, true); + } + this.dataLayer.getDataLayerContainer().removeLayer(this.layer); + this.layer.off(); + } + + public isEditing() { + return false; + } + + protected bindEvents(): void { + if (this.dataLayer.isSelectable()) { + this.layer.on('click', () => { + if (!this.isEditing()) { + this.dataLayer.getMap().selectItem(this); + } + }); + } + const clickAction = this.settings.click; + if (clickAction && clickAction.type !== WidgetActionType.doNothing) { + this.layer.on('click', (event) => { + this.dataLayer.getMap().dataItemClick(event.originalEvent, clickAction, this.data); + }); + } + } + + protected enableEdit(): void { + if (this.dataLayer.isHoverable()) { + this.addItemClass('tb-hoverable'); + } + if (this.dataLayer.allowDrag()) { + this.enableDrag(); + this.addItemClass('tb-draggable'); + } + } + + protected disableEdit(): void { + if (this.dataLayer.isHoverable()) { + this.removeItemClass('tb-hoverable'); + } + if (this.dataLayer.isDragEnabled()) { + this.disableDrag(); + this.removeItemClass('tb-draggable'); + } + } + + protected updateSelectedState() { + if (this.selected) { + this.addItemClass('tb-selected'); + } else { + this.removeItemClass('tb-selected'); + } + } + + protected updateTooltip(data: FormattedData, dsData: FormattedData[]) { + if (this.settings.tooltip.show) { + updateTooltip(this.dataLayer.getMap(), this.tooltip, + this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, data, dsData); + } + } + + protected updateLabel(data: FormattedData, dsData: FormattedData[]) { + if (this.settings.label.show) { + this.unbindLabel(); + const label = this.dataLayer.dataLayerLabelProcessor.processPattern(data, dsData); + const labelColor = this.dataLayer.getCtx().widgetConfig.color; + const content: L.Content = `
    ${label}
    `; + this.bindLabel(content); + } + } + + protected canDeselect(cancel = false): boolean { + return true; + } + + protected onSelected(): L.TB.ToolbarButtonOptions[] { + return []; + } + + protected onDeselected(): void {} + + protected abstract create(data: FormattedData, dsData: FormattedData[]): L; + + protected abstract doUpdate(data: FormattedData, dsData: FormattedData[]): void; + + protected abstract doInvalidateCoordinates(data: FormattedData, dsData: FormattedData[]): void; + + protected abstract unbindLabel(): void; + + protected abstract bindLabel(content: L.Content): void; + + protected abstract addItemClass(clazz: string): void; + + protected abstract removeItemClass(clazz: string): void; + + protected abstract enableDrag(): void; + + protected abstract disableDrag(): void; + + protected abstract updateBubblingMouseEvents(): void; + + protected abstract removeDataItemTitle(): string; + + protected abstract removeDataItem(): Observable; + +} + +export interface UnplacedMapDataItem { + entity: FormattedData; + dataLayer: TbLatestMapDataLayer; +} + +export abstract class TbLatestMapDataLayer = any, L extends L.Layer = L.Layer> extends TbMapDataLayer> implements L.TB.DataLayer { + + protected addEnabled = false; + protected dragEnabled = false; + protected editEnabled = false; + protected removeEnabled = false; + + protected editable = false; + protected selectable = false; + protected hoverable = false; + + private editMode = false; + + private unplacedItems: UnplacedMapDataItem[] = []; + + protected constructor(map: TbMap, + inputSettings: S) { + super(map, inputSettings); + if (this.settings.edit?.enabledActions) { + this.addEnabled = this.settings.edit.enabledActions.includes(DataLayerEditAction.add); + this.dragEnabled = this.settings.edit.enabledActions.includes(DataLayerEditAction.move); + this.editEnabled = this.settings.edit.enabledActions.includes(DataLayerEditAction.edit); + this.removeEnabled = this.settings.edit.enabledActions.includes(DataLayerEditAction.remove); + + this.editable = this.addEnabled || this.dragEnabled || this.editEnabled || this.removeEnabled; + this.selectable = this.removeEnabled || this.editEnabled; + this.hoverable = this.selectable || this.dragEnabled; + } + this.snappable = this.settings.edit?.snappable; + this.enableEditMode(); + } + + public isEditMode(): boolean { + return this.editMode; + } + + public isAddEnabled(): boolean { + return this.addEnabled; + } + + public isDragEnabled(): boolean { + return this.dragEnabled; + } + + public allowDrag(): boolean { + return this.dragEnabled && (!this.map.useDragModeButton() || this.map.dragModeEnabled()); + } + + public isEditEnabled(): boolean { + return this.editEnabled; + } + + public isRemoveEnabled(): boolean { + return this.removeEnabled; + } + + public isEditable(): boolean { + return this.editable; + } + + public isHoverable(): boolean { + return this.hoverable; + } + + public isSelectable(): boolean { + return this.selectable; + } + + public isSnappable(): boolean { + return this.snappable; + } + + public updateData(dsData: FormattedData[]) { + this.unplacedItems.length = 0; + const layerData = dsData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId)); + const toDelete = new Set(Array.from(this.layerItems.keys())); + const updatedItems: TbLatestDataLayerItem[] = []; + layerData.forEach((data) => { + if (this.isValidLayerData(data)) { + let layerItem = this.layerItems.get(data.entityId); + if (layerItem) { + layerItem.update(data, dsData); + updatedItems.push(layerItem); + } else { + layerItem = this.createLayerItem(data, dsData); + this.layerItems.set(data.entityId, layerItem); + } + toDelete.delete(data.entityId); + } else { + this.unplacedItems.push({ + entity: data, + dataLayer: this + }); + } + }); + toDelete.forEach((key) => { + this.removeItem(key); + }); + if (updatedItems.length) { + this.layerItemsUpdated(updatedItems); + } + } + + public hasUnplacedItems(): boolean { + return !!this.unplacedItems.length; + } + + public getUnplacedItems(): UnplacedMapDataItem[] { + return this.prepareUnplacedItems(); + } + + public enableEditMode() { + if (this.editable) { + if (!this.editMode) { + this.editMode = true; + this.updateItemsEditMode(); + } + } + } + + public disableEditMode() { + if (this.editMode) { + this.editMode = false; + this.updateItemsEditMode(); + } + } + + public dragModeUpdated() { + this.updateItemsDragMode(); + } + + protected createDataLayerContainer(): L.FeatureGroup { + return L.featureGroup([], {snapIgnore: !this.settings.edit?.snappable}); + } + + protected onDataLayerEnabled(): void { + this.updateItemsEditMode(); + } + + protected onDataLayerDisabled(): void { + for (const item of this.layerItems) { + if (item[1].isSelected()) { + this.getMap().deselectItem(false, true); + break; + } + } + } + + protected createItemFromUnplaced(unplacedItem: UnplacedMapDataItem): void { + const index = this.unplacedItems.indexOf(unplacedItem); + if (index > -1) { + this.unplacedItems.splice(index, 1); + const layerItem = this.createLayerItem(unplacedItem.entity, this.map.getData()); + this.layerItems.set(unplacedItem.entity.entityId, layerItem); + this.map.enabledDataLayersUpdated(); + } + } + + protected layerItemsUpdated(_updatedItems: TbLatestDataLayerItem[]): void { + } + + private prepareUnplacedItems(): UnplacedMapDataItem[] { + const div = document.createElement('div'); + for (const item of this.unplacedItems) { + if (!item.entity.entityDisplayName) { + if (this.settings.label.show) { + div.innerHTML = this.dataLayerLabelProcessor.processPattern(item.entity, this.getMap().getData()); + item.entity.entityDisplayName = div.textContent || div.innerText || ''; + } else { + item.entity.entityDisplayName = item.entity.entityName; + } + } + } + return this.unplacedItems; + } + + private updateItemsEditMode() { + this.layerItems.forEach(item => item.editModeUpdated()); + } + + private updateItemsDragMode() { + this.layerItems.forEach(item => item.dragModeUpdated()); + } + + public abstract placeItem(item: UnplacedMapDataItem, layer: L.Layer): void; + + protected abstract isValidLayerData(layerData: FormattedData): boolean; + + protected abstract createLayerItem(data: FormattedData, dsData: FormattedData[]): TbLatestDataLayerItem; + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts new file mode 100644 index 0000000000..63d77c48b2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts @@ -0,0 +1,311 @@ +/// +/// 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 { + DataLayerColorSettings, + DataLayerColorType, + DataLayerPatternSettings, + DataLayerPatternType, + MapDataLayerSettings, + MapDataLayerType, + mapDataSourceSettingsToDatasource, + MapStringFunction, + MapType, + TbMapDatasource +} from '@shared/models/widget/maps/map.models'; +import { + createLabelFromPattern, + guid, + isDefined, + isDefinedAndNotNull, + isNumber, + isNumeric, + mergeDeepIgnoreArray, + parseTbFunction, + safeExecuteTbFunction +} from '@core/utils'; +import L from 'leaflet'; +import { CompiledTbFunction } from '@shared/models/js-function.models'; +import { forkJoin, Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { DataKey, FormattedData } from '@shared/models/widget.models'; +import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { ColorRange } from '@shared/models/widget-settings.models'; + +export class DataLayerPatternProcessor { + + private patternFunction: CompiledTbFunction; + private pattern: string; + + constructor(private dataLayer: TbMapDataLayer, + private settings: DataLayerPatternSettings) {} + + public setup(): Observable { + if (this.settings.type === DataLayerPatternType.function) { + return parseTbFunction(this.dataLayer.getCtx().http, this.settings.patternFunction, ['data', 'dsData']).pipe( + map((parsed) => { + this.patternFunction = parsed; + return null; + }) + ); + } else { + this.pattern = this.settings.pattern; + return of(null) + } + } + + public processPattern(data: FormattedData, dsData: FormattedData[]): string { + let pattern: string; + if (this.settings.type === DataLayerPatternType.function) { + pattern = safeExecuteTbFunction(this.patternFunction, [data, dsData]); + } else { + pattern = this.pattern; + } + const text = createLabelFromPattern(pattern, data); + const customTranslate = this.dataLayer.getCtx().$injector.get(CustomTranslatePipe); + return customTranslate.transform(text); + } + +} + +export class DataLayerColorProcessor { + + private colorFunction: CompiledTbFunction; + private color: string; + private rangeKey: DataKey; + private range: ColorRange[]; + + constructor(private dataLayer: TbMapDataLayer, + private settings: DataLayerColorSettings) {} + + public setup(): Observable { + this.color = this.settings.color; + if (this.settings.type === DataLayerColorType.range) { + this.rangeKey = this.settings.rangeKey; + this.range = this.settings.range; + } else if (this.settings.type === DataLayerColorType.function) { + return parseTbFunction(this.dataLayer.getCtx().http, this.settings.colorFunction, ['data', 'dsData']).pipe( + map((parsed) => { + this.colorFunction = parsed; + return null; + }) + ); + } + return of(null) + } + + public processColor(data: FormattedData, dsData: FormattedData[]): string { + let color: string; + if (this.settings.type === DataLayerColorType.function) { + color = safeExecuteTbFunction(this.colorFunction, [data, dsData]); + if (!color) { + color = this.color; + } + } else if (this.settings.type === DataLayerColorType.range) { + color = this.color; + if (this.rangeKey && this.range?.length) { + const value = data[this.rangeKey.label]; + if (isDefinedAndNotNull(value) && isNumeric(value)) { + const num = Number(value); + for (const range of this.range) { + if (DataLayerColorProcessor.constantRange(range) && range.from === num) { + color = range.color; + break; + } else if ((!isNumber(range.from) || num >= range.from) && (!isNumber(range.to) || num < range.to)) { + color = range.color; + break; + } + } + } + } + } else { + color = this.color; + } + return color; + } + + static constantRange(range: ColorRange): boolean { + return isNumber(range.from) && isNumber(range.to) && range.from === range.to; + } + +} + +export abstract class TbDataLayerItem { + + protected layer: L; + + protected constructor(protected settings: S, + protected dataLayer: D) {} + + public getLayer(): L { + return this.layer; + } + + public getDataLayer(): D { + return this.dataLayer; + } + + public abstract remove(): void; + + public abstract invalidateCoordinates(): void; + +} + +export abstract class TbMapDataLayer { + + protected settings: S; + + protected datasource: TbMapDatasource; + + protected mapDataId: string; + + protected dataLayerContainer: L.FeatureGroup; + + protected layerItems = new Map(); + + protected groupsState: {[group: string]: boolean} = {}; + + protected enabled = true; + + protected snappable = false; + + public dataLayerLabelProcessor: DataLayerPatternProcessor; + public dataLayerTooltipProcessor: DataLayerPatternProcessor; + + protected constructor(protected map: TbMap, + inputSettings: S) { + this.settings = mergeDeepIgnoreArray({} as S, this.defaultBaseSettings(map) as S, inputSettings); + if (this.settings.groups?.length) { + this.settings.groups.forEach((group) => { + this.groupsState[group] = true; + }); + } + this.dataLayerContainer = this.createDataLayerContainer(); + this.dataLayerLabelProcessor = this.settings.label.show ? new DataLayerPatternProcessor(this, this.settings.label) : null; + this.dataLayerTooltipProcessor = this.settings.tooltip.show ? new DataLayerPatternProcessor(this, this.settings.tooltip): null; + this.map.getMap().addLayer(this.dataLayerContainer); + } + + public setup(): Observable { + this.datasource = mapDataSourceSettingsToDatasource(this.settings); + this.datasource.dataKeys = this.settings.additionalDataKeys ? [...this.settings.additionalDataKeys] : []; + const colorRangeKeys = this.allColorSettings().filter(settings => settings.type === DataLayerColorType.range && settings.rangeKey) + .map(settings => settings.rangeKey); + this.datasource.dataKeys.push(...colorRangeKeys); + this.mapDataId = this.datasource.mapDataIds[0]; + this.datasource = this.setupDatasource(this.datasource); + return forkJoin( + [ + this.dataLayerLabelProcessor ? this.dataLayerLabelProcessor.setup() : of(null), + this.dataLayerTooltipProcessor ? this.dataLayerTooltipProcessor.setup() : of(null), + this.doSetup() + ]); + } + + public removeItem(key: string): void { + const item = this.layerItems.get(key); + if (item) { + item.remove(); + this.layerItems.delete(key); + } + } + + public invalidateCoordinates(): void { + this.layerItems.forEach(item => item.invalidateCoordinates()); + } + + public getCtx(): WidgetContext { + return this.map.getCtx(); + } + + public getMap(): TbMap { + return this.map; + } + + public mapType(): MapType { + return this.map.type(); + } + + public getDatasource(): TbMapDatasource { + return this.datasource; + } + + public getDataLayerContainer(): L.FeatureGroup { + return this.dataLayerContainer; + } + + public getBounds(): L.LatLngBounds { + return this.dataLayerContainer.getBounds(); + } + + public isEnabled(): boolean { + return this.enabled; + } + + public getGroups(): string[] { + return this.settings.groups || []; + } + + public toggleGroup(group: string): boolean { + if (isDefined(this.groupsState[group])) { + this.groupsState[group] = !this.groupsState[group]; + const enabled = Object.values(this.groupsState).some(v => v); + if (this.enabled !== enabled) { + this.enabled = enabled; + if (this.enabled) { + this.map.getMap().addLayer(this.dataLayerContainer); + this.onDataLayerEnabled(); + } else { + this.onDataLayerDisabled(); + this.map.getMap().removeLayer(this.dataLayerContainer); + } + this.map.enabledDataLayersUpdated(); + return true; + } + } + return false; + } + + public hasData(data: FormattedData): boolean { + return data.$datasource.mapDataIds.includes(this.mapDataId); + } + + protected createDataLayerContainer(): L.FeatureGroup { + return L.featureGroup([], {snapIgnore: true}); + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + return datasource; + } + + protected allColorSettings(): DataLayerColorSettings[] { + return []; + } + + protected onDataLayerEnabled(): void {} + + protected onDataLayerDisabled(): void {} + + public abstract dataLayerType(): MapDataLayerType; + + protected abstract defaultBaseSettings(map: TbMap): Partial; + + protected abstract doSetup(): Observable; + +} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts new file mode 100644 index 0000000000..99cb5a9b7d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts @@ -0,0 +1,737 @@ +/// +/// 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 { + BaseMarkerShapeSettings, + ClusterMarkerColorFunction, + DataLayerColorSettings, + DataLayerColorType, + defaultBaseMarkersDataLayerSettings, + isValidLatLng, + loadImageWithAspect, + MapDataLayerType, + MapType, + MarkerIconInfo, + MarkerIconSettings, + MarkerImageFunction, + MarkerImageInfo, + MarkerImageSettings, + MarkerImageType, + MarkerPositionFunction, + MarkersDataLayerSettings, + MarkerShapeSettings, + MarkerType, + TbMapDatasource +} from '@shared/models/widget/maps/map.models'; +import L, { FeatureGroup } from 'leaflet'; +import { FormattedData } from '@shared/models/widget.models'; +import { forkJoin, Observable, of } from 'rxjs'; +import { CompiledTbFunction } from '@shared/models/js-function.models'; +import { + deepClone, + isDefined, + isDefinedAndNotNull, + isEmptyStr, + parseTbFunction, + safeExecuteTbFunction +} from '@core/utils'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import tinycolor from 'tinycolor2'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { + createColorMarkerIconElement, + createColorMarkerShapeURI, + MarkerIconContainer, + MarkerShape +} from '@shared/models/widget/maps/marker-shape.models'; +import { MatIconRegistry } from '@angular/material/icon'; +import { DomSanitizer } from '@angular/platform-browser'; +import { + TbLatestDataLayerItem, + TbLatestMapDataLayer, + UnplacedMapDataItem +} from '@home/components/widget/lib/maps/data-layer/latest-map-data-layer'; +import { TbImageMap } from '@home/components/widget/lib/maps/image-map'; +import { DataLayerColorProcessor, TbMapDataLayer } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; + +export class MarkerDataProcessor { + + private positionFunction: CompiledTbFunction; + private markerIconProcessor: MarkerIconProcessor; + + constructor(public dataLayer: TbMapDataLayer, + private settings: S, + public markerOffset: L.LatLngTuple, + public tooltipOffset: L.LatLngTuple) { + } + + public setup(): Observable { + this.markerIconProcessor = MarkerIconProcessor.fromSettings(this, this.settings); + const setup$: Observable[] = [this.markerIconProcessor.setup()]; + if (this.dataLayer.mapType() === MapType.image) { + setup$.push( + parseTbFunction(this.dataLayer.getCtx().http, this.settings.positionFunction, ['origXPos', 'origYPos', 'data', 'dsData', 'aspect']).pipe( + map((parsed) => { + this.positionFunction = parsed; + return null; + }) + ) + ); + } + return forkJoin(setup$).pipe(map(() => null)); + } + + public extractLocation(data: FormattedData, dsData: FormattedData[]): L.LatLng { + let locationData = this.extractLocationData(data); + if (locationData) { + if (this.dataLayer.mapType() === MapType.image && this.positionFunction) { + const imageMap = this.dataLayer.getMap() as TbImageMap; + locationData = this.positionFunction.execute(locationData.x, locationData.y, data, dsData, imageMap.getAspect()) || {x: 0, y: 0}; + } + return this.dataLayer.getMap().locationDataToLatLng(locationData); + } else { + return null; + } + } + + public extractLocationData(data: FormattedData): {x: number; y: number} { + if (data) { + const xKeyVal = data[this.settings.xKey.label]; + const yKeyVal = data[this.settings.yKey.label]; + switch (this.dataLayer.mapType()) { + case MapType.geoMap: + if (!isValidLatLng(xKeyVal, yKeyVal)) { + return null; + } + break; + case MapType.image: + if (!isDefinedAndNotNull(xKeyVal) || isEmptyStr(xKeyVal) || isNaN(xKeyVal) || !isDefinedAndNotNull(yKeyVal) || isEmptyStr(yKeyVal) || isNaN(yKeyVal)) { + return null; + } + break; + } + return {x: xKeyVal, y: yKeyVal}; + } else { + return null; + } + } + + public createMarkerIcon(data: FormattedData, + dsData: FormattedData[], + rotationAngle?: number): Observable { + return this.markerIconProcessor.createMarkerIcon(data, dsData, rotationAngle); + } + + public createDefaultMarkerIcon(rotationAngle = 0): Observable { + const color = this.settings.markerShape?.color?.color || '#307FE5'; + return this.createColoredMarkerShape(MarkerShape.markerShape1, tinycolor(color), rotationAngle); + } + + public createColoredMarkerShape(shape: MarkerShape, color: tinycolor.Instance, rotationAngle = 0, size = 34): Observable { + return createColorMarkerShapeURI(this.dataLayer.getCtx().$injector.get(MatIconRegistry), this.dataLayer.getCtx().$injector.get(DomSanitizer), shape, color).pipe( + map((iconUrl) => { + let icon: L.Icon; + if (rotationAngle === 0) { + icon = L.icon({ + iconUrl, + iconSize: [size, size], + iconAnchor: [size * this.markerOffset[0], size * this.markerOffset[1]], + popupAnchor: [size * this.tooltipOffset[0], size * this.tooltipOffset[1]] + }); + } else { + const style = `background-image: url(${iconUrl}); transform: rotate(${rotationAngle}deg); height: ${size}px; width: ${size}px;`; + icon = L.divIcon({ + html: `
    `, + className: 'tb-marker-div-icon', + iconSize: [size, size], + iconAnchor: [size * this.markerOffset[0], size * this.markerOffset[1]], + popupAnchor: [size * this.tooltipOffset[0], size * this.tooltipOffset[1]] + }); + } + return { + size: [size, size], + icon + } + }) + ); + } + + public createColoredMarkerIcon(iconContainer: MarkerIconContainer, + icon: string, color: tinycolor.Instance, rotationAngle = 0, size = 34): Observable { + return createColorMarkerIconElement(this.dataLayer.getCtx().$injector.get(MatIconRegistry), this.dataLayer.getCtx().$injector.get(DomSanitizer), + iconContainer, icon, color).pipe( + map((element) => { + if (rotationAngle !== 0) { + element.style.transform = `rotate(${rotationAngle}deg)`; + } + return { + size: [size, size], + icon: L.divIcon({ + html: element.outerHTML, + className: 'tb-marker-div-icon', + iconSize: [size, size], + iconAnchor: [size * this.markerOffset[0], size * this.markerOffset[1]], + popupAnchor: [size * this.tooltipOffset[0], size * this.tooltipOffset[1]] + }) + }; + }) + ); + } +} + + +abstract class MarkerIconProcessor { + + static fromSettings(dataProcessor: MarkerDataProcessor, + settings: MarkersDataLayerSettings): MarkerIconProcessor { + switch (settings.markerType) { + case MarkerType.shape: + return new ShapeMarkerIconProcessor(dataProcessor, settings.markerShape); + case MarkerType.icon: + return new IconMarkerIconProcessor(dataProcessor, settings.markerIcon); + case MarkerType.image: + return new ImageMarkerIconProcessor(dataProcessor, settings.markerImage); + } + } + + protected constructor(protected dataProcessor: MarkerDataProcessor, + protected settings: S) {} + + public abstract setup(): Observable; + + public abstract createMarkerIcon(data: FormattedData, + dsData: FormattedData[], + rotationAngle?: number): Observable; + +} + +abstract class BaseColorMarkerShapeProcessor extends MarkerIconProcessor { + + private colorProcessor: DataLayerColorProcessor; + private defaultMarkerIconInfo: MarkerIconInfo; + + protected constructor(protected dataProcessor: MarkerDataProcessor, + protected settings: S) { + super(dataProcessor, settings); + } + + public setup(): Observable { + const colorSettings = this.settings.color; + this.colorProcessor = new DataLayerColorProcessor(this.dataProcessor.dataLayer, colorSettings); + const setup$: Observable[] = [this.colorProcessor.setup()]; + if (colorSettings.type === DataLayerColorType.constant) { + const color = tinycolor(colorSettings.color); + setup$.push( + this.createMarkerShape(color, 0, this.settings.size).pipe( + map((info) => { + this.defaultMarkerIconInfo = info; + return null; + })) + ); + } + return forkJoin(setup$).pipe(map(() => null)); + } + + public createMarkerIcon(data: FormattedData, dsData: FormattedData[], rotationAngle = 0): Observable { + const colorSettings = this.settings.color; + if (colorSettings.type === DataLayerColorType.constant && rotationAngle === 0) { + return of(this.defaultMarkerIconInfo); + } else { + const color = this.colorProcessor.processColor(data, dsData); + return this.createMarkerShape(tinycolor(color), rotationAngle, this.settings.size); + } + } + + protected abstract createMarkerShape(color: tinycolor.Instance, rotationAngle: number, size: number): Observable; +} + +class ShapeMarkerIconProcessor extends BaseColorMarkerShapeProcessor { + + constructor(protected dataProcessor: MarkerDataProcessor, + protected settings: MarkerShapeSettings) { + super(dataProcessor, settings); + } + + protected createMarkerShape(color: tinycolor.Instance, rotationAngle: number, size: number): Observable { + return this.dataProcessor.createColoredMarkerShape(this.settings.shape, color, rotationAngle, size); + } + +} + +class IconMarkerIconProcessor extends BaseColorMarkerShapeProcessor { + + constructor(protected dataProcessor: MarkerDataProcessor, + protected settings: MarkerIconSettings) { + super(dataProcessor, settings); + } + + protected createMarkerShape(color: tinycolor.Instance, rotationAngle: number, size: number): Observable { + return this.dataProcessor.createColoredMarkerIcon(this.settings.iconContainer, this.settings.icon, color, rotationAngle, size); + } + +} + +class ImageMarkerIconProcessor extends MarkerIconProcessor { + + private markerImageFunction: CompiledTbFunction; + + private defaultMarkerIconInfo: MarkerIconInfo; + + constructor(protected dataProcessor: MarkerDataProcessor, + protected settings: MarkerImageSettings) { + super(dataProcessor, settings); + } + + public setup(): Observable { + if (this.settings.type === MarkerImageType.function) { + return parseTbFunction(this.dataProcessor.dataLayer.getCtx().http, this.settings.imageFunction, ['data', 'images', 'dsData']).pipe( + map((parsed) => { + this.markerImageFunction = parsed; + return null; + }) + ); + } else { + const currentImage: MarkerImageInfo = { + url: this.settings.image, + size: this.settings.imageSize || 34 + }; + return this.loadMarkerIconInfo(currentImage, 0).pipe( + map((iconInfo) => { + this.defaultMarkerIconInfo = iconInfo; + return null; + } + )); + } + } + + public createMarkerIcon(data: FormattedData, dsData: FormattedData[], rotationAngle = 0): Observable { + if (this.settings.type === MarkerImageType.function) { + const currentImage: MarkerImageInfo = safeExecuteTbFunction(this.markerImageFunction, [data, this.settings.images, dsData]); + return this.loadMarkerIconInfo(currentImage, rotationAngle); + } else if (rotationAngle === 0) { + return of(this.defaultMarkerIconInfo); + } else { + const currentImage: MarkerImageInfo = { + url: this.settings.image, + size: this.settings.imageSize || 34 + }; + return this.loadMarkerIconInfo(currentImage, rotationAngle); + } + } + + private loadMarkerIconInfo(image: MarkerImageInfo, rotationAngle = 0): Observable { + if (image && image.url) { + return loadImageWithAspect(this.dataProcessor.dataLayer.getCtx().$injector.get(ImagePipe), image.url).pipe( + switchMap((aspectImage) => { + if (aspectImage?.aspect) { + let width: number; + let height: number; + if (aspectImage.aspect > 1) { + width = image.size; + height = image.size / aspectImage.aspect; + } else { + width = image.size * aspectImage.aspect; + height = image.size; + } + let iconAnchor = image.markerOffset; + let popupAnchor = image.tooltipOffset; + if (!iconAnchor) { + iconAnchor = [width * this.dataProcessor.markerOffset[0], height * this.dataProcessor.markerOffset[1]]; + } + if (!popupAnchor) { + popupAnchor = [width * this.dataProcessor.tooltipOffset[0], height * this.dataProcessor.tooltipOffset[1]]; + } + let icon: L.Icon; + if (rotationAngle === 0) { + icon = L.icon({ + iconUrl: aspectImage.url, + iconSize: [width, height], + iconAnchor, + popupAnchor + }); + } else { + const style = `background-image: url(${aspectImage.url}); background-size: contain; transform: rotate(${rotationAngle}deg); height: ${height}px; width: ${width}px;`; + icon = L.divIcon({ + html: `
    `, + className: 'tb-marker-div-icon', + iconSize: [width, height], + iconAnchor, + popupAnchor + }); + } + const iconInfo: MarkerIconInfo = { + size: [width, height], + icon + }; + return of(iconInfo); + } else { + return this.dataProcessor.createDefaultMarkerIcon(rotationAngle); + } + }), + catchError(() => this.dataProcessor.createDefaultMarkerIcon(rotationAngle)) + ); + } else { + return this.dataProcessor.createDefaultMarkerIcon(rotationAngle); + } + } +} + +class TbMarkerDataLayerItem extends TbLatestDataLayerItem { + + private marker: L.Marker; + private labelOffset: L.PointTuple; + private iconClassList: string[]; + private moving = false; + + constructor(data: FormattedData, + dsData: FormattedData[], + protected settings: MarkersDataLayerSettings, + protected dataLayer: TbMarkersDataLayer) { + super(data, dsData, settings, dataLayer); + } + + public isEditing() { + return this.moving; + } + + public updateBubblingMouseEvents() { + this.marker.options.bubblingMouseEvents = !this.dataLayer.isEditMode(); + } + + protected create(data: FormattedData, dsData: FormattedData[]): L.Marker { + this.iconClassList = []; + const location = this.dataLayer.dataProcessor.extractLocation(data, dsData); + this.marker = L.marker(location, { + tbMarkerData: data, + snapIgnore: !this.dataLayer.isSnappable(), + bubblingMouseEvents: !this.dataLayer.isEditMode() + }); + this.updateMarkerIcon(data, dsData); + return this.marker; + } + + protected unbindLabel() { + this.marker.unbindTooltip(); + } + + protected bindLabel(content: L.Content): void { + this.marker.bindTooltip(content, { className: 'tb-marker-label', permanent: true, direction: 'top', offset: this.labelOffset }); + } + + protected doUpdate(data: FormattedData, dsData: FormattedData[]): void { + this.marker.options.tbMarkerData = data; + this.updateMarkerLocation(data, dsData); + this.updateTooltip(data, dsData); + this.updateMarkerIcon(data, dsData); + } + + protected doInvalidateCoordinates(data: FormattedData, dsData: FormattedData[]): void { + this.updateMarkerLocation(data, dsData); + } + + protected addItemClass(clazz: string): void { + if (!this.iconClassList.includes(clazz)) { + this.iconClassList.push(clazz); + this.marker.options.icon.options.className = this.updateIconClasses(this.marker.options.icon.options.className); + if ((this.marker as any)._icon) { + L.DomUtil.addClass((this.marker as any)._icon, clazz); + } + } + } + + protected removeItemClass(clazz: string): void { + const index = this.iconClassList.indexOf(clazz); + if (index !== -1) { + this.iconClassList.splice(index, 1); + this.marker.options.icon.options.className = this.updateIconClasses(this.marker.options.icon.options.className, clazz); + if ((this.marker as any)._icon) { + L.DomUtil.removeClass((this.marker as any)._icon, clazz); + } + } + } + + protected enableDrag(): void { + if (this.settings.markerClustering?.enable) { + this.marker.options.draggable = true; + this.marker.on('dragstart', () => { + this.moving = true; + }); + this.marker.on('dragend', () => { + this.saveMarkerLocation(); + this.moving = false; + }); + } else { + this.marker.pm.setOptions({ + snappable: this.dataLayer.isSnappable() + }); + this.marker.pm.enableLayerDrag(); + this.marker.on('pm:dragstart', () => { + this.moving = true; + }); + this.marker.on('pm:dragend', () => { + this.saveMarkerLocation(); + this.moving = false; + }); + } + } + + protected disableDrag(): void { + if (this.settings.markerClustering?.enable) { + this.marker.options.draggable = false; + this.marker.off('dragstart'); + this.marker.off('dragend'); + } else { + this.marker.pm.disableLayerDrag(); + this.marker.off('pm:dragstart'); + this.marker.off('pm:dragend'); + } + } + + protected removeDataItemTitle(): string { + return this.dataLayer.getCtx().translate.instant('widgets.maps.data-layer.marker.remove-marker-for', {entityName: this.data.entityName}); + } + + protected removeDataItem(): Observable { + return this.dataLayer.saveMarkerLocation(this.data, null); + } + + private saveMarkerLocation() { + const location = this.marker.getLatLng(); + this.dataLayer.saveMarkerLocation(this.data, location).subscribe(); + } + + private updateMarkerLocation(data: FormattedData, dsData: FormattedData[]) { + const location = this.dataLayer.dataProcessor.extractLocation(data, dsData); + if (!this.marker.getLatLng().equals(location) && !this.moving) { + this.marker.setLatLng(location); + } + } + + private updateMarkerIcon(data: FormattedData, dsData: FormattedData[]) { + this.dataLayer.dataProcessor.createMarkerIcon(data, dsData).subscribe( + (iconInfo) => { + let icon: L.Icon | L.DivIcon; + const options = deepClone(iconInfo.icon.options); + options.className = this.updateIconClasses(options.className); + if (iconInfo.icon instanceof L.DivIcon) { + icon = L.divIcon(options); + } else { + icon = L.icon(options as L.IconOptions); + } + this.marker.setIcon(icon); + const anchor = options.iconAnchor; + if (anchor && Array.isArray(anchor)) { + this.labelOffset = [iconInfo.size[0] / 2 - anchor[0], 10 - anchor[1]]; + } else { + this.labelOffset = [0, -iconInfo.size[1] * this.dataLayer.markerOffset[1] + 10]; + } + this.updateLabel(data, dsData); + this.editModeUpdated(); + } + ); + } + + private updateIconClasses(className: string, toRemove?: string): string { + const classes: string[] = []; + if (className?.length) { + classes.push(...className.split(' ')); + } + if (toRemove?.length) { + const index = classes.indexOf(toRemove); + if (index !== -1) { + classes.splice(index, 1); + } + } + this.iconClassList.forEach(clazz => { + if (!classes.includes(clazz)) { + classes.push(clazz); + } + }); + return classes.join(' '); + } +} + +export class TbMarkersDataLayer extends TbLatestMapDataLayer { + + public dataProcessor: MarkerDataProcessor; + + public markerOffset: L.LatLngTuple; + public tooltipOffset: L.LatLngTuple; + + private markersClusterContainer: L.MarkerClusterGroup; + private clusterMarkerColorFunction: CompiledTbFunction; + + constructor(protected map: TbMap, + inputSettings: MarkersDataLayerSettings) { + super(map, inputSettings); + } + + public dataLayerType(): MapDataLayerType { + return 'markers'; + } + + public placeItem(item: UnplacedMapDataItem, layer: L.Layer): void { + if (layer instanceof L.Marker) { + const position = layer.getLatLng(); + this.saveMarkerLocation(item.entity, position).subscribe( + (converted) => { + item.entity[this.settings.xKey.label] = converted.x; + item.entity[this.settings.yKey.label] = converted.y; + this.createItemFromUnplaced(item); + } + ); + } else { + console.warn('Unable to place item, layer is not a marker.'); + } + } + + public saveMarkerLocation(data: FormattedData, position: L.LatLng): Observable<{x: number; y: number}> { + const converted = this.map.latLngToLocationData(position); + const locationData = [ + { + dataKey: this.settings.xKey, + value: converted.x + }, + { + dataKey: this.settings.yKey, + value: converted.y + } + ]; + return this.map.saveItemData(data.$datasource, locationData, this.settings.edit?.attributeScope).pipe( + map(() => converted) + ); + } + + protected createDataLayerContainer(): FeatureGroup { + if (this.settings.markerClustering?.enable) { + return this.createMarkersClusterContainer(); + } else { + return super.createDataLayerContainer(); + } + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + datasource.dataKeys.push(this.settings.xKey, this.settings.yKey); + return datasource; + } + + protected allColorSettings(): DataLayerColorSettings[] { + if (this.settings.markerType === MarkerType.shape) { + return [this.settings.markerShape.color]; + } else if (this.settings.markerType === MarkerType.icon) { + return [this.settings.markerIcon.color]; + } + return []; + } + + protected defaultBaseSettings(map: TbMap): Partial { + return defaultBaseMarkersDataLayerSettings(map.type()); + } + + protected doSetup(): Observable { + this.markerOffset = [ + isDefined(this.settings.markerOffsetX) ? this.settings.markerOffsetX : 0.5, + isDefined(this.settings.markerOffsetY) ? this.settings.markerOffsetY : 1, + ]; + this.tooltipOffset = [ + isDefined(this.settings.tooltip?.offsetX) ? this.settings.tooltip?.offsetX : 0, + isDefined(this.settings.tooltip?.offsetY) ? this.settings.tooltip?.offsetY : -1, + ]; + this.dataProcessor = new MarkerDataProcessor(this, this.settings, this.markerOffset, this.tooltipOffset); + const setup$: Observable[] = [this.dataProcessor.setup()]; + if (this.settings.markerClustering?.enable && this.settings.markerClustering.useClusterMarkerColorFunction) { + setup$.push( + parseTbFunction(this.getCtx().http, this.settings.markerClustering.clusterMarkerColorFunction, ['data', 'childCount']).pipe( + map((parsed) => { + this.clusterMarkerColorFunction = parsed; + return null; + }) + ) + ); + } + return forkJoin(setup$).pipe(map(() => null)); + } + + protected isValidLayerData(layerData: FormattedData): boolean { + return !!this.dataProcessor.extractLocationData(layerData); + } + + protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbMarkerDataLayerItem { + return new TbMarkerDataLayerItem(data, dsData, this.settings, this); + } + + protected layerItemsUpdated(updatedItems: TbLatestDataLayerItem[]) { + if (this.settings.markerClustering?.enable) { + this.markersClusterContainer.refreshClusters(updatedItems.map(item => item.getLayer())); + } + super.layerItemsUpdated(updatedItems); + } + + private createMarkersClusterContainer(): L.FeatureGroup { + const markerClusterOptions: L.MarkerClusterGroupOptions = { + spiderfyOnMaxZoom: this.settings.markerClustering?.spiderfyOnMaxZoom, + zoomToBoundsOnClick: this.settings.markerClustering?.zoomOnClick, + showCoverageOnHover: this.settings.markerClustering?.showCoverageOnHover, + removeOutsideVisibleBounds: this.settings.markerClustering?.lazyLoad, + animate: this.settings.markerClustering?.zoomAnimation, + chunkedLoading: this.settings.markerClustering?.chunkedLoad, + snapIgnore: !this.settings.edit?.snappable + }; + if (this.settings.markerClustering?.useClusterMarkerColorFunction) { + markerClusterOptions.iconCreateFunction = (cluster) => { + const childCount = cluster.getChildCount(); + const data = cluster.getAllChildMarkers().map(clusterMarker => clusterMarker.options.tbMarkerData); + const markerColor: string = this.clusterMarkerColorFunction ? + safeExecuteTbFunction(this.clusterMarkerColorFunction, [data, childCount]) : null; + if (isDefinedAndNotNull(markerColor) && tinycolor(markerColor).isValid()) { + const parsedColor = tinycolor(markerColor); + const alpha = parsedColor.getAlpha(); + return L.divIcon({ + html: `
    ` + + `
    ` + childCount + '
    ', + iconSize: new L.Point(40, 40), + className: 'tb-cluster-marker-container' + }); + } else { + let c = ' marker-cluster-'; + if (childCount < 10) { + c += 'small'; + } else if (childCount < 100) { + c += 'medium'; + } else { + c += 'large'; + } + return new L.DivIcon({ + html: '
    ' + childCount + '
    ', + className: 'marker-cluster' + c, + iconSize: new L.Point(40, 40) + }); + } + } + } + if (this.settings.markerClustering?.maxClusterRadius && this.settings.markerClustering.maxClusterRadius > 0) { + markerClusterOptions.maxClusterRadius = Math.floor(this.settings.markerClustering.maxClusterRadius); + } + if (this.settings.markerClustering?.maxZoom && this.settings.markerClustering.maxZoom >= 0 && this.settings.markerClustering.maxZoom < 19) { + markerClusterOptions.disableClusteringAtZoom = Math.floor(this.settings.markerClustering.maxZoom); + } + this.markersClusterContainer = new L.MarkerClusterGroup(markerClusterOptions); + return this.markersClusterContainer; + } + +} 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 new file mode 100644 index 0000000000..36ab097c5d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts @@ -0,0 +1,435 @@ +/// +/// 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 { + defaultBasePolygonsDataLayerSettings, + isCutPolygon, isJSON, MapDataLayerType, + PolygonsDataLayerSettings, + TbMapDatasource, TbPolyData, TbPolygonCoordinates, TbPolygonRawCoordinates +} from '@shared/models/widget/maps/map.models'; +import L from 'leaflet'; +import { FormattedData } from '@shared/models/widget.models'; +import { TbShapesDataLayer } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { Observable } from 'rxjs'; +import { isNotEmptyStr, isString } from '@core/utils'; +import { + TbLatestDataLayerItem, + UnplacedMapDataItem +} from '@home/components/widget/lib/maps/data-layer/latest-map-data-layer'; +import { map } from 'rxjs/operators'; + +class TbPolygonDataLayerItem extends TbLatestDataLayerItem { + + private polygonContainer: L.FeatureGroup; + private polygon: L.Polygon; + private polygonStyle: L.PathOptions; + private editing = false; + + constructor(data: FormattedData, + dsData: FormattedData[], + protected settings: PolygonsDataLayerSettings, + protected dataLayer: TbPolygonsDataLayer) { + super(data, dsData, settings, dataLayer); + } + + public isEditing() { + return this.editing; + } + + public updateBubblingMouseEvents() { + this.polygon.options.bubblingMouseEvents = !this.dataLayer.isEditMode(); + } + + protected create(data: FormattedData, 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, + snapIgnore: !this.dataLayer.isSnappable(), + bubblingMouseEvents: !this.dataLayer.isEditMode() + }); + + this.polygonContainer = L.featureGroup(); + this.polygon.addTo(this.polygonContainer); + + this.updateLabel(data, dsData); + return this.polygonContainer; + } + + protected unbindLabel() { + this.polygonContainer.unbindTooltip(); + } + + protected bindLabel(content: L.Content): void { + this.polygonContainer.bindTooltip(content, {className: 'tb-polygon-label', permanent: true, direction: 'center'}) + .openTooltip(this.polygonContainer.getBounds().getCenter()); + } + + protected doUpdate(data: FormattedData, 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); + } + } + + protected doInvalidateCoordinates(data: FormattedData, _dsData: FormattedData[]): void { + this.updatePolygonShape(data); + } + + protected addItemClass(clazz: string): void { + if ((this.polygon as any)._path) { + L.DomUtil.addClass((this.polygon as any)._path, clazz); + } + } + + protected removeItemClass(clazz: string): void { + if ((this.polygon as any)._path) { + L.DomUtil.removeClass((this.polygon as any)._path, clazz); + } + } + + protected enableDrag(): void { + this.polygon.pm.setOptions({ + snappable: this.dataLayer.isSnappable() + }); + this.polygon.pm.enableLayerDrag(); + this.polygon.on('pm:dragstart', () => { + this.editing = true; + }); + this.polygon.on('pm:drag', () => { + if (this.tooltip?.isOpen()) { + this.tooltip.setLatLng(this.polygon.getBounds().getCenter()); + } + }); + this.polygon.on('pm:dragend', () => { + this.savePolygonCoordinates(); + this.editing = false; + }); + } + + protected disableDrag(): void { + this.polygon.pm.disableLayerDrag(); + this.polygon.off('pm:dragstart'); + this.polygon.off('pm:dragend'); + } + + protected onSelected(): L.TB.ToolbarButtonOptions[] { + const buttons: L.TB.ToolbarButtonOptions[] = []; + if (this.dataLayer.isEditEnabled()) { + this.enablePolygonEditMode(); + buttons.push( + { + id: 'cut', + title: this.getDataLayer().getCtx().translate.instant('widgets.maps.data-layer.polygon.cut'), + iconClass: 'tb-cut', + click: (e, button) => { + const map = this.dataLayer.getMap().getMap(); + if (!map.pm.globalCutModeEnabled()) { + this.disablePolygonRotateMode(); + this.disablePolygonEditMode(); + this.enablePolygonCutMode(button); + } else { + this.disablePolygonCutMode(button); + this.enablePolygonEditMode(); + } + } + }, + { + id: 'rotate', + title: this.getDataLayer().getCtx().translate.instant('widgets.maps.data-layer.polygon.rotate'), + iconClass: 'tb-rotate', + click: (e, button) => { + if (!this.polygon.pm.rotateEnabled()) { + this.disablePolygonCutMode(); + this.disablePolygonEditMode(); + this.enablePolygonRotateMode(button); + } else { + this.disablePolygonRotateMode(button); + this.enablePolygonEditMode(); + } + } + } + ); + } + return buttons; + } + + protected onDeselected(): void { + if (this.dataLayer.isEditEnabled()) { + this.disablePolygonEditMode(); + this.disablePolygonCutMode(); + this.disablePolygonRotateMode(); + } + } + + protected canDeselect(cancel = false): boolean { + const map = this.dataLayer.getMap().getMap(); + if (map.pm.globalCutModeEnabled()) { + if (cancel) { + this.disablePolygonCutMode(); + } + return false; + } else if (this.polygon.pm.rotateEnabled()) { + if (cancel) { + this.disablePolygonRotateMode(); + } + return false; + } else if (this.editing) { + return false; + } + return true; + } + + protected removeDataItemTitle(): string { + return this.dataLayer.getCtx().translate.instant('widgets.maps.data-layer.polygon.remove-polygon-for', {entityName: this.data.entityName}); + } + + protected removeDataItem(): Observable { + return this.dataLayer.savePolygonCoordinates(this.data, null); + } + + private enablePolygonEditMode() { + this.polygon.on('pm:markerdragstart', () => this.editing = true); + this.polygon.on('pm:markerdragend', () => setTimeout(() => { + this.editing = false; + }) ); + this.polygon.on('pm:edit', () => this.savePolygonCoordinates()); + this.polygon.pm.enable(); + const map = this.dataLayer.getMap(); + map.getEditToolbar().getButton('remove')?.setDisabled(false); + } + + private disablePolygonEditMode() { + this.polygon.pm.disable(); + this.polygon.off('pm:markerdragstart'); + this.polygon.off('pm:markerdragend'); + this.polygon.off('pm:edit'); + const map = this.dataLayer.getMap(); + map.getEditToolbar().getButton('remove')?.setDisabled(true); + } + + private enablePolygonCutMode(cutButton?: L.TB.ToolbarButton) { + this.polygonContainer.closePopup(); + this.editing = true; + this.polygon.options.bubblingMouseEvents = true; + this.polygon.setStyle({...this.polygonStyle, 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) => { + if (e.layer instanceof L.Polygon) { + if (this.polygon instanceof L.Rectangle) { + this.polygonContainer.removeLayer(this.polygon); + this.polygon = L.polygon(e.layer.getLatLngs(), { + ...this.polygonStyle, + snapIgnore: !this.dataLayer.isSnappable(), + bubblingMouseEvents: !this.dataLayer.isEditMode() + }); + this.polygon.addTo(this.polygonContainer); + } else { + this.polygon.setLatLngs(e.layer.getLatLngs()); + } + } + // @ts-ignore + e.layer._pmTempLayer = true; + e.layer.remove(); + this.polygonContainer.removeLayer(this.polygon); + // @ts-ignore + this.polygon._pmTempLayer = false; + this.polygon.addTo(this.polygonContainer); + this.updateSelectedState(); + cutButton?.setActive(false); + this.savePolygonCoordinates() + }); + const map = this.dataLayer.getMap().getMap(); + map.pm.setLang('en', { + tooltips: { + firstVertex: this.getDataLayer().getCtx().translate.instant('widgets.maps.data-layer.polygon.polygon-place-first-point-cut-hint'), + continueLine: this.getDataLayer().getCtx().translate.instant('widgets.maps.data-layer.polygon.continue-polygon-cut-hint'), + finishPoly: this.getDataLayer().getCtx().translate.instant('widgets.maps.data-layer.polygon.finish-polygon-cut-hint') + } + }, 'en'); + map.pm.enableGlobalCutMode({ + // @ts-ignore + layersToCut: [this.polygon] + }); + // @ts-ignore + L.DomUtil.addClass(map.pm.Draw.Cut._hintMarker.getTooltip()._container, 'tb-place-item-label'); + cutButton?.setActive(true); + map.once('pm:globalcutmodetoggled', (e) => { + if (!e.enabled) { + this.disablePolygonCutMode(cutButton); + this.enablePolygonEditMode(); + } + }); + } + + private disablePolygonCutMode(cutButton?: L.TB.ToolbarButton) { + this.editing = false; + this.polygon.options.bubblingMouseEvents = !this.dataLayer.isEditMode(); + this.polygon.setStyle({...this.polygonStyle, dashArray: null}); + this.removeItemClass('tb-cut-mode'); + this.polygon.off('pm:cut'); + const map = this.dataLayer.getMap().getMap(); + map.pm.disableGlobalCutMode(); + cutButton?.setActive(false); + } + + private enablePolygonRotateMode(rotateButton?: L.TB.ToolbarButton) { + this.polygonContainer.closePopup(); + this.editing = true; + this.polygon.on('pm:rotateend', () => { + this.savePolygonCoordinates(); + }); + this.polygon.pm.enableRotate(); + rotateButton?.setActive(true); + this.polygon.on('pm:rotatedisable', () => { + this.disablePolygonRotateMode(rotateButton); + this.enablePolygonEditMode(); + }); + } + + private disablePolygonRotateMode(rotateButton?: L.TB.ToolbarButton) { + this.editing = false; + this.polygon.pm.disableRotate(); + this.polygon.off('pm:rotateend'); + this.polygon.off('pm:rotatedisable'); + rotateButton?.setActive(false); + } + + private savePolygonCoordinates() { + let coordinates: TbPolygonCoordinates = this.polygon.getLatLngs(); + if (coordinates.length === 1) { + coordinates = coordinates[0] as TbPolygonCoordinates; + } + if (this.polygon instanceof L.Rectangle && !isCutPolygon(coordinates)) { + const bounds = this.polygon.getBounds(); + const boundsArray = [bounds.getNorthWest(), bounds.getNorthEast(), bounds.getSouthWest(), bounds.getSouthEast()]; + if (coordinates.every(point => boundsArray.find(boundPoint => boundPoint.equals(point as L.LatLng)) !== undefined)) { + coordinates = [bounds.getNorthWest(), bounds.getSouthEast()]; + } + } + this.dataLayer.savePolygonCoordinates(this.data, coordinates).subscribe(); + } + + private updatePolygonShape(data: FormattedData) { + if (this.editing) { + return; + } + const polyData = this.dataLayer.extractPolygonCoordinates(data) as TbPolyData; + if (isCutPolygon(polyData) || polyData.length !== 2) { + if (this.polygon instanceof L.Rectangle) { + this.polygonContainer.removeLayer(this.polygon); + this.polygon = L.polygon(polyData, { + ...this.polygonStyle, + snapIgnore: !this.dataLayer.isSnappable(), + bubblingMouseEvents: !this.dataLayer.isEditMode() + }); + this.polygon.addTo(this.polygonContainer); + this.editModeUpdated(); + } else { + this.polygon.setLatLngs(polyData); + } + } else if (polyData.length === 2) { + const bounds = new L.LatLngBounds(polyData as L.LatLngTuple[]); + (this.polygon as L.Rectangle).setBounds(bounds); + } + } + +} + +export class TbPolygonsDataLayer extends TbShapesDataLayer { + + constructor(protected map: TbMap, + inputSettings: PolygonsDataLayerSettings) { + super(map, inputSettings); + } + + public dataLayerType(): MapDataLayerType { + return 'polygons'; + } + + public placeItem(item: UnplacedMapDataItem, layer: L.Layer): void { + if (layer instanceof L.Polygon) { + let coordinates: TbPolygonCoordinates; + if (layer instanceof L.Rectangle) { + const bounds = layer.getBounds(); + coordinates = [bounds.getNorthWest(), bounds.getSouthEast()]; + } else { + coordinates = layer.getLatLngs(); + if (coordinates.length === 1) { + coordinates = coordinates[0] as TbPolygonCoordinates; + } + } + this.savePolygonCoordinates(item.entity, coordinates).subscribe( + (converted) => { + item.entity[this.settings.polygonKey.label] = JSON.stringify(converted); + this.createItemFromUnplaced(item); + } + ); + } else { + console.warn('Unable to place item, layer is not a polygon.'); + } + } + + public extractPolygonCoordinates(data: FormattedData): TbPolygonRawCoordinates { + let rawPolyData = data[this.settings.polygonKey.label]; + if (isString(rawPolyData)) { + rawPolyData = JSON.parse(rawPolyData); + } + return this.map.polygonDataToCoordinates(rawPolyData); + } + + public savePolygonCoordinates(data: FormattedData, coordinates: TbPolygonCoordinates): Observable { + const converted = coordinates ? this.map.coordinatesToPolygonData(coordinates) : null; + const polygonData = [ + { + dataKey: this.settings.polygonKey, + value: converted + } + ]; + return this.map.saveItemData(data.$datasource, polygonData, this.settings.edit?.attributeScope).pipe( + map(() => converted) + ); + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + datasource.dataKeys.push(this.settings.polygonKey); + return datasource; + } + + protected defaultBaseSettings(map: TbMap): Partial { + return defaultBasePolygonsDataLayerSettings(map.type()); + } + + protected doSetup(): Observable { + return super.doSetup(); + } + + protected isValidLayerData(layerData: FormattedData): boolean { + return layerData && ((isNotEmptyStr(layerData[this.settings.polygonKey.label]) && !isJSON(layerData[this.settings.polygonKey.label]) + || Array.isArray(layerData[this.settings.polygonKey.label]))); + } + + protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbPolygonDataLayerItem { + return new TbPolygonDataLayerItem(data, dsData, this.settings, this); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts new file mode 100644 index 0000000000..836b0e7947 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts @@ -0,0 +1,58 @@ +/// +/// 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 { DataLayerColorSettings, ShapeDataLayerSettings, 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 { 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'; + +export abstract class TbShapesDataLayer> extends TbLatestMapDataLayer { + + public fillColorProcessor: DataLayerColorProcessor; + public 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 + }; + } + + protected allColorSettings(): DataLayerColorSettings[] { + return [this.settings.fillColor, this.settings.strokeColor]; + } + + protected doSetup(): Observable { + this.fillColorProcessor = new DataLayerColorProcessor(this, this.settings.fillColor); + this.strokeColorProcessor = new DataLayerColorProcessor(this, this.settings.strokeColor); + return forkJoin([this.fillColorProcessor.setup(), this.strokeColorProcessor.setup()]); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts new file mode 100644 index 0000000000..e598685786 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts @@ -0,0 +1,619 @@ +/// +/// 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 { + calculateInterpolationRatio, + calculateLastPoints, DataLayerColorSettings, DataLayerColorType, + defaultBaseTripsDataLayerSettings, + findRotationAngle, + interpolateLineSegment, + MapDataLayerType, MarkerType, + TbMapDatasource, + TripsDataLayerSettings +} from '@shared/models/widget/maps/map.models'; +import { forkJoin, Observable } from 'rxjs'; +import { DataKey, FormattedData, WidgetActionType } from '@shared/models/widget.models'; +import { map } from 'rxjs/operators'; +import L from 'leaflet'; +import { deepClone, isDefined, isUndefined } from '@core/utils'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import moment from 'moment/moment'; +import { createTooltip, updateTooltip } from '@home/components/widget/lib/maps/data-layer/data-layer-utils'; +import _ from 'lodash'; +import { + DataLayerColorProcessor, + DataLayerPatternProcessor, + TbDataLayerItem, + TbMapDataLayer +} from '@home/components/widget/lib/maps/data-layer/map-data-layer'; +import { MarkerDataProcessor } from '@home/components/widget/lib/maps/data-layer/markers-data-layer'; + +type TripRouteData = {[time: number]: FormattedData}; + +interface PointItem { + point: L.CircleMarker; + tooltip?: L.Popup; +} + +class TbTripDataItem extends TbDataLayerItem { + + private tripRouteData: TripRouteData; + + private marker: L.Marker; + private markerTooltip: L.Popup; + private labelOffset: L.PointTuple; + + private polyline: L.Polyline; + private polylineDecorator: L.PolylineDecorator; + private pointsContainer: L.FeatureGroup; + private points = new Map(); + + private currentTime: number; + private currentPositionData: FormattedData; + private pointData: FormattedData; + + constructor(private rawRouteData: FormattedData[], + private latestData: FormattedData, + settings: TripsDataLayerSettings, + dataLayer: TbTripsDataLayer) { + super(settings, dataLayer); + this.tripRouteData = this.prepareTripRouteData(); + this.create(); + } + + public update(rawRouteData: FormattedData[]) { + this.rawRouteData = rawRouteData; + this.tripRouteData = this.prepareTripRouteData(); + this.updateCurrentPosition(true); + this.pointData = this.currentPositionData; + if (this.latestData) { + this.pointData = {...this.pointData, ...this.latestData}; + } + this.updatePath(); + this.updatePoints(); + this.updateMarker(); + } + + public updateLatestData(latestData: FormattedData) { + this.latestData = latestData; + this.pointData = this.currentPositionData; + if (this.latestData) { + this.pointData = {...this.pointData, ...this.latestData}; + } + this.updateAppearance(); + } + + public updateAppearance() { + this.updatePathAppearance(); + const dsData = this.dataLayer.getMap().getData(); + if (this.settings.showMarker && this.settings.tooltip?.show) { + updateTooltip(this.dataLayer.getMap(), this.markerTooltip, + this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, this.pointData, dsData); + } + this.updatePoints(); + this.updateMarkerIcon(this.pointData, dsData); + } + + public updateCurrentTime() { + this.updateCurrentPosition(); + this.pointData = this.currentPositionData; + if (this.latestData) { + this.pointData = {...this.pointData, ...this.latestData}; + } + this.updatePath(); + this.updateMarker(); + } + + public invalidateCoordinates(): void { + this.tripRouteData = this.prepareTripRouteData(); + this.updatePath(); + this.updatePoints(); + this.updateMarker(); + } + + public remove() { + if (this.marker) { + this.layer.removeLayer(this.marker); + this.marker.off(); + } + this.points.forEach(pointItem => { + pointItem.point.off(); + }); + this.dataLayer.getDataLayerContainer().removeLayer(this.layer); + this.layer.off(); + } + + public calculateAnchors(): number[] { + const entries = Object.entries(this.tripRouteData); + const dsData = this.dataLayer.getMap().getData(); + return entries.filter(data => this.dataLayer.getMap().getLocationSnapFilterFunction().execute(data[1], dsData)) + .map(data => parseInt(data[0], 10)); + } + + private create() { + this.updateCurrentPosition(); + this.layer = L.featureGroup(); + this.pointData = this.currentPositionData; + if (this.latestData) { + this.pointData = {...this.pointData, ...this.latestData}; + } + this.createPath(); + this.updatePoints(); + this.createMarker(); + try { + this.dataLayer.getDataLayerContainer().addLayer(this.layer); + } catch (e) { + console.warn(e); + } + } + + private createMarker() { + if (this.settings.showMarker) { + const dsData = this.dataLayer.getMap().getData(); + const location = this.dataLayer.dataProcessor.extractLocation(this.pointData, dsData); + this.marker = L.marker(location, { + tbMarkerData: this.pointData, + snapIgnore: true + }); + this.marker.addTo(this.layer); + this.updateMarkerIcon(this.pointData, dsData); + if (this.settings.tooltip?.show) { + this.markerTooltip = createTooltip(this.dataLayer.getMap(), + this.marker, this.settings.tooltip, this.pointData, () => true); + updateTooltip(this.dataLayer.getMap(), this.markerTooltip, + this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, this.pointData, dsData); + } + const clickAction = this.settings.click; + if (clickAction && clickAction.type !== WidgetActionType.doNothing) { + this.marker.on('click', (event) => { + this.dataLayer.getMap().dataItemClick(event.originalEvent, clickAction, this.pointData); + }); + } + } + } + + private updateMarker() { + if (this.settings.showMarker) { + const dsData = this.dataLayer.getMap().getData(); + this.marker.options.tbMarkerData = this.pointData; + this.updateMarkerLocation(this.pointData, dsData); + if (this.settings.tooltip.show) { + updateTooltip(this.dataLayer.getMap(), this.markerTooltip, + this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, this.pointData, dsData); + } + this.updateMarkerIcon(this.pointData, dsData); + } + } + + private createPath() { + if (this.settings.showPath) { + const formattedRouteData = _.values(this.tripRouteData); + const dsData = this.dataLayer.getMap().getData(); + const locations = formattedRouteData.map(data => this.dataLayer.dataProcessor.extractLocation(data, dsData)); + const pathStyle = this.dataLayer.getPathStyle(this.pointData, dsData); + this.polyline = L.polyline(locations, pathStyle); + this.polyline.addTo(this.layer); + if (this.settings.usePathDecorator) { + this.polylineDecorator = new L.PolylineDecorator(this.polyline, this.dataLayer.getPathDecoratorStyle(pathStyle.color)); + this.polylineDecorator.addTo(this.layer); + } + } + } + + private updatePath() { + if (this.settings.showPath) { + const formattedRouteData = _.values(this.tripRouteData); + const dsData = this.dataLayer.getMap().getData(); + const locations = formattedRouteData.map(data => this.dataLayer.dataProcessor.extractLocation(data, dsData)); + this.polyline.setLatLngs(locations); + if (this.settings.usePathDecorator) { + this.polylineDecorator.setPaths(this.polyline); + } + this.updatePathAppearance(); + } + } + + private updatePoints() { + if (this.settings.showPoints) { + if (!this.pointsContainer) { + this.pointsContainer = L.featureGroup(); + this.pointsContainer.addTo(this.layer); + } + const formattedRouteData = _.values(this.tripRouteData); + const dsData = this.dataLayer.getMap().getData(); + const pointsData = formattedRouteData.map(data => ({ + location: this.dataLayer.dataProcessor.extractLocation(data, dsData), + data + })).filter(pData => !!pData.location); + const toDelete = new Set(Array.from(this.points.keys())); + for (const pData of pointsData) { + let pointData = pData.data; + if (this.latestData) { + pointData = {...pointData, ...this.latestData}; + } + const pointLocation = pData.location; + const pointColor = this.dataLayer.pointColorProcessor.processColor(pointData, dsData); + const pointStyle = { + stroke: false, + fillOpacity: 1, + fillColor: pointColor, + radius: this.settings.pointSize + }; + const pointKey = `${pointLocation.lat}_${pointLocation.lng}`; + let pointItem = this.points.get(pointKey); + if (pointItem) { + pointItem.point.setStyle(pointStyle); + if (this.settings.pointTooltip?.show) { + updateTooltip(this.dataLayer.getMap(), pointItem.tooltip, + this.settings.pointTooltip, this.dataLayer.pointTooltipProcessor, pointData, dsData); + } + } else { + pointItem = { + point: L.circleMarker(pointLocation, pointStyle) + }; + pointItem.point.addTo(this.pointsContainer); + if (this.settings.pointTooltip?.show) { + pointItem.tooltip = createTooltip(this.dataLayer.getMap(), + pointItem.point, this.settings.pointTooltip, pointData, () => true); + updateTooltip(this.dataLayer.getMap(), pointItem.tooltip, + this.settings.pointTooltip, this.dataLayer.pointTooltipProcessor, pointData, dsData); + } + this.points.set(pointKey, pointItem); + } + toDelete.delete(pointKey); + } + toDelete.forEach(pointKey => { + const pointItem = this.points.get(pointKey); + if (pointItem) { + this.pointsContainer.removeLayer(pointItem.point); + pointItem.point.off(); + this.points.delete(pointKey); + } + }); + } + } + + private updatePathAppearance() { + if (this.settings.showPath) { + const dsData = this.dataLayer.getMap().getData(); + const pathStyle = this.dataLayer.getPathStyle(this.pointData, dsData); + this.polyline.setStyle(pathStyle); + if (this.settings.usePathDecorator) { + this.polylineDecorator.setPatterns(this.dataLayer.getPathDecoratorStyle(pathStyle.color).patterns) + } + } + } + + private updateMarkerLocation(data: FormattedData, dsData: FormattedData[]) { + const location = this.dataLayer.dataProcessor.extractLocation(data, dsData); + if (!this.marker.getLatLng().equals(location)) { + this.marker.setLatLng(location); + } + } + + private updateMarkerIcon(data: FormattedData, dsData: FormattedData[]) { + if (this.settings.showMarker) { + this.dataLayer.dataProcessor.createMarkerIcon(data, dsData, data.rotationAngle).subscribe( + (iconInfo) => { + const options = deepClone(iconInfo.icon.options); + this.marker.setIcon(iconInfo.icon); + const anchor = options.iconAnchor; + if (anchor && Array.isArray(anchor)) { + this.labelOffset = [iconInfo.size[0] / 2 - anchor[0], 10 - anchor[1]]; + } else { + this.labelOffset = [0, -iconInfo.size[1] * this.dataLayer.markerOffset[1] + 10]; + } + this.updateMarkerLabel(data, dsData); + } + ); + } + } + + private updateMarkerLabel(data: FormattedData, dsData: FormattedData[]) { + if (this.settings.label.show) { + this.marker.unbindTooltip(); + const label = this.dataLayer.dataLayerLabelProcessor.processPattern(data, dsData); + const labelColor = this.dataLayer.getCtx().widgetConfig.color; + const content: L.Content = `
    ${label}
    `; + this.marker.bindTooltip(content, { className: 'tb-marker-label', permanent: true, direction: 'top', offset: this.labelOffset }); + } + } + + private prepareTripRouteData(): TripRouteData { + const result: TripRouteData = {}; + const minTime = this.dataLayer.getMap().getMinTime(); + const maxTime = this.dataLayer.getMap().getMaxTime(); + const timeStep = this.dataLayer.getMap().getTimeStep(); + const timeline = this.dataLayer.getMap().hasTimeline(); + const dsData = this.dataLayer.getMap().getData(); + for (const data of this.rawRouteData) { + const currentTime = data.time; + const normalizeTime = timeline ? (minTime + + Math.ceil((currentTime - minTime) / timeStep) * timeStep) : currentTime; + result[normalizeTime] = { + ...data, + minTime: minTime !== Infinity ? moment(minTime).format('YYYY-MM-DD HH:mm:ss') : '', + maxTime: maxTime !== -Infinity ? moment(maxTime).format('YYYY-MM-DD HH:mm:ss') : '', + rotationAngle: this.settings.rotateMarker ? this.settings.offsetAngle : 0 + }; + } + if (timeline) { + const xKey = this.settings.xKey.label; + const yKey = this.settings.yKey.label; + const timeStamp = Object.keys(result); + for (let i = 0; i < timeStamp.length - 1; i++) { + if (isUndefined(result[timeStamp[i + 1]][xKey]) || isUndefined(result[timeStamp[i + 1]][yKey])) { + for (let j = i + 2; j < timeStamp.length - 1; j++) { + if (isDefined(result[timeStamp[j]][xKey]) || isDefined(result[timeStamp[j]][yKey])) { + const ratio = calculateInterpolationRatio(Number(timeStamp[i]), Number(timeStamp[j]), Number(timeStamp[i + 1])); + result[timeStamp[i + 1]] = { + ...interpolateLineSegment(result[timeStamp[i]], result[timeStamp[j]], xKey, yKey, ratio), + ...result[timeStamp[i + 1]], + }; + break; + } + } + } + if (this.settings.rotateMarker) { + const startPoint = this.dataLayer.dataProcessor.extractLocation(result[timeStamp[i]], dsData); + const endPoint = this.dataLayer.dataProcessor.extractLocation(result[timeStamp[i + 1]], dsData); + result[timeStamp[i]].rotationAngle += findRotationAngle(startPoint, endPoint); + } + } + } + return result; + } + + private updateCurrentPosition(force = false) { + if (this.currentTime !== this.dataLayer.getMap().getCurrentTime() || force) { + this.currentTime = this.dataLayer.getMap().getCurrentTime(); + let currentPosition = this.tripRouteData[this.currentTime]; + if (!currentPosition) { + const timePoints = Object.keys(this.tripRouteData).map(item => parseInt(item, 10)); + for (let i = 1; i < timePoints.length; i++) { + if (timePoints[i - 1] < this.currentTime && timePoints[i] > this.currentTime) { + const beforePosition = this.tripRouteData[timePoints[i - 1]]; + const afterPosition = this.tripRouteData[timePoints[i]]; + const ratio = calculateInterpolationRatio(timePoints[i - 1], timePoints[i], this.currentTime); + currentPosition = { + ...beforePosition, + time: this.currentTime, + ...interpolateLineSegment(beforePosition, afterPosition, this.settings.xKey.label, this.settings.yKey.label, ratio) + }; + break; + } + } + } + if (!currentPosition) { + currentPosition = calculateLastPoints(this.tripRouteData, this.currentTime); + } + this.currentPositionData = currentPosition; + } + } + +} + +export class TbTripsDataLayer extends TbMapDataLayer { + + public dataProcessor: MarkerDataProcessor; + + public markerOffset: L.LatLngTuple; + public tooltipOffset: L.LatLngTuple; + + public pathStrokeColorProcessor: DataLayerColorProcessor; + public pointColorProcessor: DataLayerColorProcessor; + public pointTooltipProcessor: DataLayerPatternProcessor; + + private rawTripsData: FormattedData[][]; + private latestTripsData: FormattedData[]; + + constructor(protected map: TbMap, + inputSettings: TripsDataLayerSettings) { + super(map, inputSettings); + } + + public dataLayerType(): MapDataLayerType { + return 'trips'; + } + + public showMarker(): boolean { + return this.settings.showMarker; + } + + public prepareTripsData(tripsData: FormattedData[][], tripsLatestData: FormattedData[]): {minTime: number; maxTime: number} { + let minTime = Infinity; + let maxTime = -Infinity; + this.rawTripsData = + tripsData.filter(d => !!d.length && d[0].$datasource.mapDataIds.includes(this.mapDataId)).map( + item => this.clearIncorrectFirsLastDatapoint(item)).filter(arr => arr.length); + this.latestTripsData = tripsLatestData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId)); + this.rawTripsData.forEach((dataSource) => { + minTime = Math.min(dataSource[0].time, minTime); + maxTime = Math.max(dataSource[dataSource.length - 1].time, maxTime); + }); + return {minTime, maxTime}; + } + + public updateTrips() { + const toDelete = new Set(Array.from(this.layerItems.keys())); + this.rawTripsData.forEach(rawTripData => { + const entityId = rawTripData[0].entityId; + let tripItem = this.layerItems.get(entityId); + if (tripItem) { + tripItem.update(rawTripData); + } else { + const latestData = this.latestTripsData.find(d => d.entityId === entityId); + tripItem = new TbTripDataItem(rawTripData, latestData, this.settings, this); + this.layerItems.set(entityId, tripItem); + } + toDelete.delete(entityId); + }); + toDelete.forEach((key) => { + this.removeItem(key); + }); + } + + public updateTripsLatestData(tripsLatestData: FormattedData[]) { + this.latestTripsData = tripsLatestData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId)); + this.layerItems.forEach((item, entityId) => { + const latestData = this.latestTripsData.find(d => d.entityId === entityId); + item.updateLatestData(latestData); + }); + } + + public updateCurrentTime() { + this.layerItems.forEach(item => { + item.updateCurrentTime(); + }); + } + + public updateAppearance() { + this.layerItems.forEach(item => { + item.updateAppearance(); + }); + } + + public calculateAnchors(): number[] { + let anchors: number[] = []; + this.layerItems.forEach(item => { + const tripAnchors = item.calculateAnchors(); + anchors = [...new Set([...anchors, ...tripAnchors])]; + }); + return anchors; + } + + public getPathStyle(data: FormattedData, dsData: FormattedData[]): L.PolylineOptions { + const pathStroke = this.pathStrokeColorProcessor.processColor(data, dsData); + return { + interactive: false, + color: pathStroke, + opacity: 1, + weight: this.settings.pathStrokeWeight, + pmIgnore: true + }; + } + + public getPathDecoratorStyle(pathStroke: string): L.PolylineDecoratorOptions { + const symbolConstructor = L.Symbol[this.settings.pathDecoratorSymbol]; + return { + patterns: [ + { + offset: this.settings.pathDecoratorOffset, + endOffset: this.settings.pathEndDecoratorOffset, + repeat: this.settings.pathDecoratorRepeat, + symbol: symbolConstructor({ + pixelSize: this.settings.pathDecoratorSymbolSize, + polygon: false, + pathOptions: { + color: this.settings.pathDecoratorSymbolColor ? this.settings.pathDecoratorSymbolColor : pathStroke, + stroke: true + } + }) + } + ] + }; + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + datasource.dataKeys = [this.settings.xKey, this.settings.yKey]; + const additionalKeys = this.allColorSettings().filter(settings => settings.type === DataLayerColorType.range && settings.rangeKey) + .map(settings => settings.rangeKey); + if (this.settings.additionalDataKeys?.length) { + additionalKeys.push(...this.settings.additionalDataKeys); + } + if (additionalKeys.length) { + const tsKeys = additionalKeys.filter(key => key.type === DataKeyType.timeseries); + const latestKeys = additionalKeys.filter(key => key.type !== DataKeyType.timeseries); + datasource.dataKeys.push(...tsKeys); + if (latestKeys.length) { + datasource.latestDataKeys = latestKeys; + } + } + return datasource; + } + + protected allColorSettings(): DataLayerColorSettings[] { + const colorSettings: DataLayerColorSettings[] = []; + if (this.settings.showMarker) { + if (this.settings.markerType === MarkerType.shape) { + colorSettings.push(this.settings.markerShape.color); + } else if (this.settings.markerType === MarkerType.icon) { + colorSettings.push(this.settings.markerIcon.color); + } + } + if (this.settings.showPath) { + colorSettings.push(this.settings.pathStrokeColor); + } + if (this.settings.showPoints) { + colorSettings.push(this.settings.pointColor); + } + return colorSettings; + } + + protected defaultBaseSettings(map: TbMap): Partial { + return defaultBaseTripsDataLayerSettings(map.type()); + } + + protected doSetup(): Observable { + this.markerOffset = [ + isDefined(this.settings.markerOffsetX) ? this.settings.markerOffsetX : 0.5, + isDefined(this.settings.markerOffsetY) ? this.settings.markerOffsetY : 0.5, + ]; + this.tooltipOffset = [ + isDefined(this.settings.tooltip?.offsetX) ? this.settings.tooltip?.offsetX : 0, + isDefined(this.settings.tooltip?.offsetY) ? this.settings.tooltip?.offsetY : -0.5, + ]; + this.dataProcessor = new MarkerDataProcessor(this, this.settings, this.markerOffset, this.tooltipOffset); + const setup$: Observable[] = [this.dataProcessor.setup()]; + if (this.settings.showPath) { + this.pathStrokeColorProcessor = new DataLayerColorProcessor(this, this.settings.pathStrokeColor); + setup$.push(this.pathStrokeColorProcessor.setup()); + } + if (this.settings.showPoints) { + this.pointColorProcessor = new DataLayerColorProcessor(this, this.settings.pointColor); + setup$.push(this.pointColorProcessor.setup()); + if (this.settings.pointTooltip?.show) { + this.pointTooltipProcessor = new DataLayerPatternProcessor(this, this.settings.pointTooltip); + setup$.push(this.pointTooltipProcessor.setup()); + } + } + return forkJoin(setup$).pipe(map(() => null)); + } + + private clearIncorrectFirsLastDatapoint(dataSource: FormattedData[]): FormattedData[] { + const firstHistoricalDataIndexCoordinate = dataSource.findIndex(this.findFirstHistoricalDataIndexCoordinate); + if (firstHistoricalDataIndexCoordinate === -1) { + return []; + } + let lastIndex = dataSource.length - 1; + for (lastIndex; lastIndex > 0; lastIndex--) { + if (this.findFirstHistoricalDataIndexCoordinate(dataSource[lastIndex])) { + lastIndex++; + break; + } + } + if (firstHistoricalDataIndexCoordinate > 0 || lastIndex < dataSource.length) { + return dataSource.slice(firstHistoricalDataIndexCoordinate, lastIndex); + } + return dataSource; + } + + private findFirstHistoricalDataIndexCoordinate = (item: FormattedData): boolean => { + return isDefined(item[this.settings.xKey.label]) && isDefined(item[this.settings.yKey.label]); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts new file mode 100644 index 0000000000..fd03990d26 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts @@ -0,0 +1,183 @@ +/// +/// 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 { + DEFAULT_ZOOM_LEVEL, + defaultGeoMapSettings, + GeoMapSettings, + latLngPointToBounds, + MapZoomAction, + TbCircleData, + TbPolygonCoordinate, + TbPolygonCoordinates, + TbPolygonRawCoordinate, + TbPolygonRawCoordinates +} from '@shared/models/widget/maps/map.models'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { DeepPartial } from '@shared/models/common'; +import { forkJoin, Observable, of } from 'rxjs'; +import L from 'leaflet'; +import { map, tap } from 'rxjs/operators'; +import { TbMapLayer } from '@home/components/widget/lib/maps/map-layer'; +import { TbMap } from '@home/components/widget/lib/maps/map'; + +export class TbGeoMap extends TbMap { + + constructor(protected ctx: WidgetContext, + protected inputSettings: DeepPartial, + protected containerElement: HTMLElement) { + super(ctx, inputSettings, containerElement); + } + + protected defaultSettings(): GeoMapSettings { + return defaultGeoMapSettings; + } + + protected createMap(): Observable { + const map = L.map(this.mapElement, { + scrollWheelZoom: this.settings.zoomActions.includes(MapZoomAction.scroll), + doubleClickZoom: this.settings.zoomActions.includes(MapZoomAction.doubleClick), + zoomControl: this.settings.zoomActions.includes(MapZoomAction.controlButtons), + zoom: this.settings.defaultZoomLevel || DEFAULT_ZOOM_LEVEL, + center: this.defaultCenterPosition + }).setView(this.defaultCenterPosition, this.settings.defaultZoomLevel || DEFAULT_ZOOM_LEVEL); + return of(map); + } + + protected onResize(): void {} + + protected fitBounds(bounds: L.LatLngBounds) { + if (bounds.isValid()) { + if (!this.settings.fitMapBounds && this.settings.defaultZoomLevel) { + this.map.setZoom(this.settings.defaultZoomLevel, { animate: false }); + if (this.settings.useDefaultCenterPosition) { + this.map.panTo(this.defaultCenterPosition, { animate: false }); + } + else { + this.map.panTo(bounds.getCenter()); + } + } else { + this.map.once('zoomend', () => { + let minZoom = this.settings.minZoomLevel; + if (this.settings.defaultZoomLevel) { + minZoom = Math.max(minZoom, this.settings.defaultZoomLevel); + } + if (this.map.getZoom() > minZoom) { + this.map.setZoom(minZoom, { animate: false }); + } + }); + if (this.settings.useDefaultCenterPosition) { + bounds = bounds.extend(this.defaultCenterPosition); + } + this.map.fitBounds(bounds, { padding: [50, 50], animate: false }); + this.map.invalidateSize(); + } + } + } + + protected doSetupControls(): Observable { + return this.loadLayers().pipe( + tap((layers: L.TB.LayerData[]) => { + if (layers.length) { + const layer = layers[0]; + layer.layer.addTo(this.map); + this.map.attributionControl.setPrefix(layer.attributionPrefix); + if (layers.length > 1) { + const sidebar = this.getSidebar(); + L.TB.layers({ + layers, + sidebar, + position: this.settings.controlsPosition, + uiClass: 'tb-layers', + paneTitle: this.ctx.translate.instant('widgets.maps.layer.map-layers'), + buttonTitle: this.ctx.translate.instant('widgets.maps.layer.layers'), + }).addTo(this.map); + } + } + }) + ); + + } + + private loadLayers(): Observable { + const layers = this.settings.layers.map(settings => TbMapLayer.fromSettings(this.ctx, settings)); + return forkJoin(layers.map(layer => layer.loadLayer(this.map))).pipe( + map((layersData) => { + return layersData.filter(l => l !== null); + }) + ); + } + + public locationDataToLatLng(position: {x: number; y: number}): L.LatLng { + return L.latLng(position.x, position.y) as L.LatLng; + } + + public latLngToLocationData(position: L.LatLng): {x: number; y: number} { + position = position ? latLngPointToBounds(position, this.southWest, this.northEast, 0) : {lat: null, lng: null} as L.LatLng; + return { + x: position.lat, + y: position.lng + } + } + + public polygonDataToCoordinates(expression: TbPolygonRawCoordinates): TbPolygonRawCoordinates { + return (expression).map((el: TbPolygonRawCoordinate) => { + if (!Array.isArray(el[0]) && !Array.isArray(el[1]) && el.length === 2) { + return el; + } else if (Array.isArray(el) && el.length) { + return this.polygonDataToCoordinates(el as TbPolygonRawCoordinates) as TbPolygonRawCoordinate; + } else { + return null; + } + }).filter(el => !!el); + } + + public coordinatesToPolygonData(coordinates: TbPolygonCoordinates): TbPolygonRawCoordinates { + if (coordinates.length) { + return coordinates.map((point: TbPolygonCoordinate) => { + if (Array.isArray(point)) { + return this.coordinatesToPolygonData(point) as TbPolygonRawCoordinate; + } else { + const convertPoint = latLngPointToBounds(point, this.southWest, this.northEast); + return [convertPoint.lat, convertPoint.lng]; + } + }); + } + return []; + } + + public circleDataToCoordinates(circle: TbCircleData): TbCircleData { + const centerPoint = latLngPointToBounds(new L.LatLng(circle.latitude, circle.longitude), this.southWest, this.northEast); + circle.latitude = centerPoint.lat; + circle.longitude = centerPoint.lng; + return circle; + } + + public coordinatesToCircleData(center: L.LatLng, radius: number): TbCircleData { + let circleData: TbCircleData = null; + if (center) { + const position = latLngPointToBounds(center, this.southWest, this.northEast); + circleData = { + latitude: position.lat, + longitude: position.lng, + radius + }; + } + return circleData; + } + + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts new file mode 100644 index 0000000000..219f5d63ff --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts @@ -0,0 +1,342 @@ +/// +/// 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 { + calculateNewPointCoordinate, + defaultImageMapSettings, + defaultImageMapSourceSettings, + ImageMapSettings, + imageMapSourceSettingsToDatasource, + ImageSourceType, + loadImageWithAspect, + MapZoomAction, + TbCircleData, TbPolygonCoordinate, TbPolygonCoordinates, TbPolygonRawCoordinate, TbPolygonRawCoordinates +} from '@shared/models/widget/maps/map.models'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { DeepPartial } from '@shared/models/common'; +import { Observable, of, ReplaySubject, switchMap } from 'rxjs'; +import L from 'leaflet'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { catchError } from 'rxjs/operators'; +import { DataSet, widgetType } from '@shared/models/widget.models'; +import { WidgetSubscriptionOptions } from '@core/api/widget-api.models'; +import { isNotEmptyStr } from '@core/utils'; +import { EntityDataPageLink } from '@shared/models/query/query.models'; + +interface ImageLayerData { + imageUrl: string; + aspect: number; + update?: boolean; +} + +export class TbImageMap extends TbMap { + + private maxZoom: number; + private width: number; + private height: number; + private imageLayerData: ImageLayerData; + private initMapSubject: ReplaySubject; + + private imageOverlay: L.ImageOverlay; + + constructor(protected ctx: WidgetContext, + protected inputSettings: DeepPartial, + protected containerElement: HTMLElement) { + super(ctx, inputSettings, containerElement); + } + + protected defaultSettings(): ImageMapSettings { + return defaultImageMapSettings; + } + + protected createMap(): Observable { + this.maxZoom = 4; + this.width = 0; + this.height = 0; + this.imageLayerData = { + imageUrl: null, + aspect: 0 + }; + this.initMapSubject = new ReplaySubject(); + this.loadImageLayerData().subscribe((data) => { + this.imageLayerData = data; + if (this.imageLayerData.update) { + this.onResize(true); + } else { + this.onResize(); + this.initMapSubject.next(this.map); + this.initMapSubject.complete(); + } + }); + return this.initMapSubject.asObservable(); + } + + protected onResize(updateImage?: boolean): void { + let width = this.mapElement.clientWidth; + if (width > 0 && this.imageLayerData.aspect) { + let height = Math.round(width / this.imageLayerData.aspect); + const imageMapHeight = this.mapElement.clientHeight; + if (imageMapHeight > 0 && height > imageMapHeight) { + height = imageMapHeight; + width = Math.round(height * this.imageLayerData.aspect); + } + width *= this.maxZoom; + const prevWidth = this.width; + const prevHeight = this.height; + if (this.width !== width || updateImage) { + this.width = width; + this.height = Math.round(width / this.imageLayerData.aspect); + if (!this.map) { + this.doCreateMap(updateImage); + } else { + const lastCenterPos = this.latLngToPoint(this.map.getCenter()); + lastCenterPos.x /= prevWidth; + lastCenterPos.y /= prevHeight; + this.updateMaxBounds(updateImage, lastCenterPos); + (this.map as any)._enforcingBounds = true; + this.map.invalidateSize(false); + (this.map as any)._enforcingBounds = false; + this.invalidateDataLayersCoordinates(); + } + } + } + } + + protected fitBounds(_bounds: L.LatLngBounds) {} + + public locationDataToLatLng(position: {x: number; y: number}): L.LatLng { + return this.pointToLatLng( + position.x * this.width, + position.y * this.height); + } + + public latLngToLocationData(position: L.LatLng): {x: number; y: number} { + if (!position) { + return { + x: null, + y: null + }; + } + const point = this.latLngToPoint(position); + const posX = calculateNewPointCoordinate(point.x, this.width); + const posY = calculateNewPointCoordinate(point.y, this.height); + return { + x: posX, + y: posY + }; + } + + public polygonDataToCoordinates(expression: TbPolygonRawCoordinates): TbPolygonRawCoordinates { + return expression.map((el: TbPolygonRawCoordinate) => { + if (!Array.isArray(el[0]) && !Array.isArray(el[1]) && el.length === 2) { + const latLng = this.pointToLatLng( + el[0] * this.width, + el[1] * this.height + ); + return [latLng.lat, latLng.lng] as TbPolygonRawCoordinate; + } else if (Array.isArray(el) && el.length) { + return this.polygonDataToCoordinates(el as TbPolygonRawCoordinates) as TbPolygonRawCoordinate; + } else { + return null; + } + }).filter(el => !!el); + } + + public coordinatesToPolygonData(coordinates: TbPolygonCoordinates): TbPolygonRawCoordinates { + if (coordinates.length) { + return coordinates.map((point: TbPolygonCoordinate) => { + if (Array.isArray(point)) { + return this.coordinatesToPolygonData(point) as TbPolygonRawCoordinate; + } else { + const pos = this.latLngToPoint(point); + return [calculateNewPointCoordinate(pos.x, this.width), calculateNewPointCoordinate(pos.y, this.height)]; + } + }); + } else { + return []; + } + } + + public circleDataToCoordinates(circle: TbCircleData): TbCircleData { + const centerPoint = this.pointToLatLng(circle.latitude * this.width, circle.longitude * this.height); + circle.latitude = centerPoint.lat; + circle.longitude = centerPoint.lng; + circle.radius = circle.radius * this.width; + return circle; + } + + public coordinatesToCircleData(center: L.LatLng, radius: number): TbCircleData { + let circleData: TbCircleData = null; + if (center) { + const point = this.latLngToPoint(center); + const posX = calculateNewPointCoordinate(point.x, this.width); + const posY = calculateNewPointCoordinate(point.y, this.height); + const convertedRadius = calculateNewPointCoordinate(radius, this.width); + circleData = { + latitude: posX, + longitude: posY, + radius: convertedRadius + }; + } + return circleData; + } + + public getAspect(): number { + return this.imageLayerData.aspect; + } + + private pointToLatLng(x: number, y: number): L.LatLng { + return L.CRS.Simple.pointToLatLng({ x, y } as L.PointExpression, this.maxZoom - 1); + } + + private latLngToPoint(latLng: L.LatLngLiteral): L.Point { + return L.CRS.Simple.latLngToPoint(latLng, this.maxZoom - 1); + } + + private doCreateMap(updateImage?: boolean) { + if (!this.map && this.imageLayerData.aspect > 0) { + const center = this.pointToLatLng(this.width / 2, this.height / 2); + this.map = L.map(this.mapElement, { + scrollWheelZoom: this.settings.zoomActions.includes(MapZoomAction.scroll), + doubleClickZoom: this.settings.zoomActions.includes(MapZoomAction.doubleClick), + zoomControl: this.settings.zoomActions.includes(MapZoomAction.controlButtons), + minZoom: 1, + maxZoom: this.maxZoom, + zoom: 1, + center, + crs: L.CRS.Simple, + attributionControl: false + }); + this.updateMaxBounds(updateImage); + } + } + + private updateMaxBounds(updateImage?: boolean, lastCenterPos?: L.Point) { + const w = this.width; + const h = this.height; + this.southWest = this.pointToLatLng(0, h); + this.northEast = this.pointToLatLng(w, 0); + const bounds = new L.LatLngBounds(this.southWest, this.northEast); + + if (updateImage && this.imageOverlay) { + this.imageOverlay.remove(); + this.imageOverlay = null; + } + + if (this.imageOverlay) { + this.imageOverlay.setBounds(bounds); + } else { + this.imageOverlay = L.imageOverlay(this.imageLayerData.imageUrl, bounds).addTo(this.map); + } + const padding = 200 * this.maxZoom; + const southWest = this.pointToLatLng(-padding, h + padding); + const northEast = this.pointToLatLng(w + padding, -padding); + const maxBounds = new L.LatLngBounds(southWest, northEast); + (this.map as any)._enforcingBounds = true; + this.map.setMaxBounds(maxBounds); + if (lastCenterPos) { + lastCenterPos.x *= w; + lastCenterPos.y *= h; + const center = this.pointToLatLng(lastCenterPos.x, lastCenterPos.y); + this.map.panTo(center, { animate: false }); + } + (this.map as any)._enforcingBounds = false; + } + + private loadImageLayerData(): Observable { + const imageSource = this.settings.imageSource; + if (imageSource.sourceType === ImageSourceType.image) { + return this.imageFromUrl(imageSource.url); + } else { + const datasource = imageMapSourceSettingsToDatasource(imageSource); + const result = new ReplaySubject<[DataSet, boolean]>(); + let isUpdate = false; + const imageUrlSubscriptionOptions: WidgetSubscriptionOptions = { + datasources: [datasource], + hasDataPageLink: true, + singleEntity: true, + useDashboardTimewindow: false, + type: widgetType.latest, + callbacks: { + onDataUpdated: (subscription) => { + if (isNotEmptyStr(subscription.data[0]?.data[0]?.[1])) { + result.next([subscription.data[0].data, isUpdate]); + } else { + result.next([[[0, imageSource.url]], isUpdate]); + } + isUpdate = true; + } + } + }; + this.ctx.subscriptionApi.createSubscription(imageUrlSubscriptionOptions, true).subscribe((subscription) => { + const pageLink: EntityDataPageLink = { + page: 0, + pageSize: 1, + textSearch: null, + dynamic: true + }; + subscription.subscribeAllForPaginatedData(pageLink, null); + }); + return this.imageFromEntityData(result); + } + } + + private imageFromUrl(url: string, update = false): Observable { + return loadImageWithAspect(this.ctx.$injector.get(ImagePipe), url).pipe( + switchMap( aspectImage => { + if (aspectImage) { + return of({ + imageUrl: aspectImage.url, + aspect: aspectImage.aspect, + update + }); + } else { + return this.imageFromUrl(defaultImageMapSourceSettings.url, update); + } + } + ), + catchError(() => this.imageFromUrl(defaultImageMapSourceSettings.url, update)) + ); + } + + private imageFromEntityData(entityData: Observable<[DataSet, boolean]>): Observable { + return entityData.pipe( + switchMap(res => { + const update = res[1]; + const url = res[0][0][1]; + const layerData: ImageLayerData = { + imageUrl: null, + aspect: null, + update + }; + return loadImageWithAspect(this.ctx.$injector.get(ImagePipe), url).pipe( + switchMap((aspectImage) => { + if (aspectImage) { + layerData.aspect = aspectImage.aspect; + layerData.imageUrl = aspectImage.url; + return of(layerData); + } else { + return this.imageFromUrl(defaultImageMapSourceSettings.url, update); + } + }), + catchError(() => this.imageFromUrl(defaultImageMapSourceSettings.url, update)) + ); + }) + ); + } + +} 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 new file mode 100644 index 0000000000..79e2f11ce6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts @@ -0,0 +1,972 @@ +/// +/// 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 L, { TB } from 'leaflet'; +import { guid, isNotEmptyStr } from '@core/utils'; +import 'leaflet-providers'; +import { Map as MapLibreGLMap, LngLat as MapLibreGLLngLat } from 'maplibre-gl'; +import '@geoman-io/leaflet-geoman-free'; +import 'leaflet.markercluster'; +import { MatIconRegistry } from '@angular/material/icon'; +import { isSvgIcon, splitIconName } from '@shared/models/icon.models'; +import { catchError, take } from 'rxjs/operators'; +import { of } from 'rxjs'; + +L.MarkerCluster = L.MarkerCluster.mergeOptions({ pmIgnore: true }); + +class SidebarControl extends L.Control implements L.TB.SidebarControl { + + private readonly sidebar: JQuery; + + private current = $(); + private currentButton = $(); + + private map: L.Map; + + private buttonContainer: JQuery; + + constructor(options: TB.SidebarControlOptions) { + super(options); + this.sidebar = $('
    '); + this.options.container.append(this.sidebar); + const position = options?.position || 'topleft'; + if (['topleft', 'bottomleft'].includes(position)) { + this.options.container.addClass('tb-sidebar-left'); + } else { + this.options.container.addClass('tb-sidebar-right'); + } + } + + addPane(pane: JQuery, button: JQuery): this { + pane.hide().appendTo(this.sidebar); + button.appendTo(this.buttonContainer); + return this; + } + + togglePane(pane: JQuery, button: JQuery) { + const paneWidth = this.options?.paneWidth || 220; + const position = this.options?.position || 'topleft'; + + this.current.hide().trigger('hide'); + this.currentButton.removeClass('active'); + + + if (this.current === pane) { + if (['topleft', 'bottomleft'].includes(position)) { + this.map.panBy([-paneWidth, 0], { animate: false }); + } + this.sidebar.hide(); + this.current = this.currentButton = $(); + } else { + this.sidebar.show(); + if (['topleft', 'bottomleft'].includes(position) && !this.current.length) { + this.map.panBy([paneWidth, 0], { animate: false }); + } + this.current = pane; + this.currentButton = button || $(); + } + this.current.show().trigger('show'); + this.currentButton.addClass('active'); + this.map.invalidateSize({ pan: false, animate: false }); + } + + onAdd(map: L.Map): HTMLElement { + this.buttonContainer = $("
    ") + .attr('class', 'leaflet-bar'); + return this.buttonContainer[0]; + } + + addTo(map: L.Map): this { + this.map = map; + return super.addTo(map); + } +} + +class SidebarPaneControl extends L.Control implements L.TB.SidebarPaneControl { + + private button: JQuery; + private $ui: JQuery; + + constructor(options: O) { + super(options); + } + + addTo(map: L.Map): this { + + this.button = $("") + .attr('class', 'tb-control-button') + .attr('href', '#') + .attr('role', 'button') + .html('
    ') + .on('click', (e) => { + this.toggle(e); + }); + if (this.options.buttonTitle) { + this.button.attr('title', this.options.buttonTitle); + } + + this.$ui = $('
    ') + .attr('class', this.options.uiClass); + + $('
    ') + .appendTo(this.$ui) + .append($('
    ') + .text(this.options.paneTitle)) + .append($('
    ') + .append($('') + .attr('aria-label', 'Close') + .bind('click', (e) => { + this.toggle(e); + }))); + + this.options.sidebar.addPane(this.$ui, this.button); + + this.onAddPane(map, this.button, this.$ui, (e) => { + this.toggle(e); + }); + + return this; + } + + public onAddPane(map: L.Map, button: JQuery, $ui: JQuery, toggle: (e: JQuery.MouseEventBase) => void) {} + + private toggle(e: JQuery.MouseEventBase) { + e.stopPropagation(); + e.preventDefault(); + if (!this.button.hasClass("disabled")) { + this.options.sidebar.togglePane(this.$ui, this.button); + } + } +} + +class LayersControl extends SidebarPaneControl implements L.TB.LayersControl { + constructor(options: TB.LayersControlOptions) { + super(options); + } + + public onAddPane(map: L.Map, button: JQuery, $ui: JQuery, toggle: (e: JQuery.MouseEventBase) => void) { + const paneId = guid(); + const layers = this.options.layers; + const baseSection = $("
    ") + .attr('class', 'tb-layers-container') + .appendTo($ui); + + layers.forEach((layerData, i) => { + const id = `map-ui-layer-${paneId}-${i}`; + const buttonContainer = $('
    ') + .appendTo(baseSection); + const mapContainer = $('
    ') + .appendTo(buttonContainer); + const input = $('') + .prop('id', id) + .prop('checked', map.hasLayer(layerData.layer)) + .appendTo(buttonContainer); + + const item = $('
    ") + .attr('class', 'tb-layers-container') + .appendTo($ui); + + groups.forEach((groupData, i) => { + const id = `map-group-layer-${paneId}-${i}`; + const checkBoxContainer = $('
    ') + .appendTo(baseSection); + const input = $('') + .prop('id', id) + .prop('checked', groupData.enabled) + .appendTo(checkBoxContainer); + + $('
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.ts index 8984e3889f..cccc826ff4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.ts @@ -47,7 +47,8 @@ export class AlarmsTableKeySettingsComponent extends WidgetSettingsComponent { useCellContentFunction: false, cellContentFunction: '', defaultColumnVisibility: 'visible', - columnSelectionToDisplay: 'enabled' + columnSelectionToDisplay: 'enabled', + disableSorting: false }; } @@ -61,6 +62,7 @@ export class AlarmsTableKeySettingsComponent extends WidgetSettingsComponent { cellContentFunction: [settings.cellContentFunction, [Validators.required]], defaultColumnVisibility: [settings.defaultColumnVisibility, []], columnSelectionToDisplay: [settings.columnSelectionToDisplay, []], + disableSorting: [settings.disableSorting, []] }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html index 718edb23d2..ba613ddbfe 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html @@ -94,10 +94,52 @@ {{ 'widgets.table.display-pagination' | translate }} +
    +
    {{ 'widgets.table.page-step-settings' | translate }}
    +
    +
    widgets.table.page-step-increment
    + + + + warning + + + +
    widgets.table.page-step-count
    + + + + warning + + +
    +
    widgets.table.default-page-size
    - - + + + + {{ size }} + +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts index 772bf3937d..72194dcb22 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts @@ -19,6 +19,7 @@ import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.m import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; +import { buildPageStepSizeValues } from '@home/components/widget/lib/table-widget.models'; @Component({ selector: 'tb-alarms-table-widget-settings', @@ -28,6 +29,7 @@ import { AppState } from '@core/core.state'; export class AlarmsTableWidgetSettingsComponent extends WidgetSettingsComponent { alarmsTableWidgetSettingsForm: UntypedFormGroup; + pageStepSizeValues = []; constructor(protected store: Store, private fb: UntypedFormBuilder) { @@ -56,6 +58,8 @@ export class AlarmsTableWidgetSettingsComponent extends WidgetSettingsComponent displayActivity: true, displayPagination: true, defaultPageSize: 10, + pageStepIncrement: null, + pageStepCount: 3, defaultSortOrder: '-createdTime', useRowStyleFunction: false, rowStyleFunction: '' @@ -80,31 +84,45 @@ export class AlarmsTableWidgetSettingsComponent extends WidgetSettingsComponent displayActivity: [settings.displayActivity, []], displayPagination: [settings.displayPagination, []], defaultPageSize: [settings.defaultPageSize, [Validators.min(1)]], + pageStepCount: [settings.pageStepCount ?? 3, [Validators.min(1), Validators.max(100), + Validators.required, Validators.pattern(/^\d*$/)]], + pageStepIncrement: [settings.pageStepIncrement ?? settings.defaultPageSize, + [Validators.min(1), Validators.required, Validators.pattern(/^\d*$/)]], defaultSortOrder: [settings.defaultSortOrder, []], useRowStyleFunction: [settings.useRowStyleFunction, []], rowStyleFunction: [settings.rowStyleFunction, [Validators.required]] }); + this.pageStepSizeValues = buildPageStepSizeValues(this.alarmsTableWidgetSettingsForm.get('pageStepCount').value, + this.alarmsTableWidgetSettingsForm.get('pageStepIncrement').value); } protected validatorTriggers(): string[] { - return ['useRowStyleFunction', 'displayPagination']; + return ['useRowStyleFunction', 'displayPagination', 'pageStepCount', 'pageStepIncrement']; } - protected updateValidators(emitEvent: boolean) { + protected updateValidators(emitEvent: boolean, trigger: string) { + if (trigger === 'pageStepCount' || trigger === 'pageStepIncrement') { + this.alarmsTableWidgetSettingsForm.get('defaultPageSize').reset(); + this.pageStepSizeValues = buildPageStepSizeValues(this.alarmsTableWidgetSettingsForm.get('pageStepCount').value, + this.alarmsTableWidgetSettingsForm.get('pageStepIncrement').value); + return; + } const useRowStyleFunction: boolean = this.alarmsTableWidgetSettingsForm.get('useRowStyleFunction').value; const displayPagination: boolean = this.alarmsTableWidgetSettingsForm.get('displayPagination').value; if (useRowStyleFunction) { - this.alarmsTableWidgetSettingsForm.get('rowStyleFunction').enable(); + this.alarmsTableWidgetSettingsForm.get('rowStyleFunction').enable({emitEvent}); } else { - this.alarmsTableWidgetSettingsForm.get('rowStyleFunction').disable(); + this.alarmsTableWidgetSettingsForm.get('rowStyleFunction').disable({emitEvent}); } if (displayPagination) { - this.alarmsTableWidgetSettingsForm.get('defaultPageSize').enable(); + this.alarmsTableWidgetSettingsForm.get('defaultPageSize').enable({emitEvent}); + this.alarmsTableWidgetSettingsForm.get('pageStepCount').enable({emitEvent: false}); + this.alarmsTableWidgetSettingsForm.get('pageStepIncrement').enable({emitEvent: false}); } else { - this.alarmsTableWidgetSettingsForm.get('defaultPageSize').disable(); + this.alarmsTableWidgetSettingsForm.get('defaultPageSize').disable({emitEvent}); + this.alarmsTableWidgetSettingsForm.get('pageStepCount').disable({emitEvent: false}); + this.alarmsTableWidgetSettingsForm.get('pageStepIncrement').disable({emitEvent: false}); } - this.alarmsTableWidgetSettingsForm.get('rowStyleFunction').updateValueAndValidity({emitEvent}); - this.alarmsTableWidgetSettingsForm.get('defaultPageSize').updateValueAndValidity({emitEvent}); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.html index 1dcd4f1f3f..1905888bb6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.html @@ -47,6 +47,11 @@
    +
    + + {{ 'widgets.table.disable-sorting' | translate }} + +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.ts index 1bc28baf53..ff314d9d2d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.ts @@ -45,7 +45,8 @@ export class TimeseriesTableKeySettingsComponent extends WidgetSettingsComponent useCellContentFunction: false, cellContentFunction: '', defaultColumnVisibility: 'visible', - columnSelectionToDisplay: 'enabled' + columnSelectionToDisplay: 'enabled', + disableSorting: false }; } @@ -57,6 +58,7 @@ export class TimeseriesTableKeySettingsComponent extends WidgetSettingsComponent cellContentFunction: [settings.cellContentFunction, [Validators.required]], defaultColumnVisibility: [settings.defaultColumnVisibility, []], columnSelectionToDisplay: [settings.columnSelectionToDisplay, []], + disableSorting: [settings.disableSorting, []] }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.html index 54b2a370e6..684258bea7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.html @@ -56,6 +56,11 @@
    +
    + + {{ 'widgets.table.disable-sorting' | translate }} + +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.ts index a0ee7e7c70..2e2f414f2c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.ts @@ -46,7 +46,8 @@ export class TimeseriesTableLatestKeySettingsComponent extends WidgetSettingsCom useCellContentFunction: false, cellContentFunction: '', defaultColumnVisibility: 'visible', - columnSelectionToDisplay: 'enabled' + columnSelectionToDisplay: 'enabled', + disableSorting: false }; } @@ -60,6 +61,7 @@ export class TimeseriesTableLatestKeySettingsComponent extends WidgetSettingsCom cellContentFunction: [settings.cellContentFunction, [Validators.required]], defaultColumnVisibility: [settings.defaultColumnVisibility, []], columnSelectionToDisplay: [settings.columnSelectionToDisplay, []], + disableSorting: [settings.disableSorting, []] }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html index 44f33ce42e..61a688f36f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html @@ -61,10 +61,52 @@ {{ 'widgets.table.display-pagination' | translate }} +
    +
    {{ 'widgets.table.page-step-settings' | translate }}
    +
    +
    widgets.table.page-step-increment
    + + + + warning + + + +
    widgets.table.page-step-count
    + + + + warning + + +
    +
    widgets.table.default-page-size
    - - + + + + {{ size }} + +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts index 1e4a5d9660..7826f48dec 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts @@ -19,6 +19,7 @@ import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.m import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; +import { buildPageStepSizeValues } from '@home/components/widget/lib/table-widget.models'; @Component({ selector: 'tb-timeseries-table-widget-settings', @@ -28,6 +29,7 @@ import { AppState } from '@core/core.state'; export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsComponent { timeseriesTableWidgetSettingsForm: UntypedFormGroup; + pageStepSizeValues = []; constructor(protected store: Store, private fb: UntypedFormBuilder) { @@ -51,6 +53,8 @@ export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsCompon displayPagination: true, useEntityLabel: false, defaultPageSize: 10, + pageStepIncrement: null, + pageStepCount: 3, hideEmptyLines: false, disableStickyHeader: false, useRowStyleFunction: false, @@ -77,32 +81,46 @@ export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsCompon displayPagination: [settings.displayPagination, []], useEntityLabel: [settings.useEntityLabel, []], defaultPageSize: [settings.defaultPageSize, [Validators.min(1)]], + pageStepCount: [settings.pageStepCount ?? 3, [Validators.min(1), Validators.max(100), + Validators.required, Validators.pattern(/^\d*$/)]], + pageStepIncrement: [settings.pageStepIncrement ?? settings.defaultPageSize, + [Validators.min(1), Validators.required, Validators.pattern(/^\d*$/)]], hideEmptyLines: [settings.hideEmptyLines, []], disableStickyHeader: [settings.disableStickyHeader, []], useRowStyleFunction: [settings.useRowStyleFunction, []], rowStyleFunction: [settings.rowStyleFunction, [Validators.required]] }); + this.pageStepSizeValues = buildPageStepSizeValues(this.timeseriesTableWidgetSettingsForm.get('pageStepCount').value, + this.timeseriesTableWidgetSettingsForm.get('pageStepIncrement').value); } protected validatorTriggers(): string[] { - return ['useRowStyleFunction', 'displayPagination']; + return ['useRowStyleFunction', 'displayPagination', 'pageStepCount', 'pageStepIncrement']; } - protected updateValidators(emitEvent: boolean) { + protected updateValidators(emitEvent: boolean, trigger: string) { + if (trigger === 'pageStepCount' || trigger === 'pageStepIncrement') { + this.timeseriesTableWidgetSettingsForm.get('defaultPageSize').reset(); + this.pageStepSizeValues = buildPageStepSizeValues(this.timeseriesTableWidgetSettingsForm.get('pageStepCount').value, + this.timeseriesTableWidgetSettingsForm.get('pageStepIncrement').value); + return; + } const useRowStyleFunction: boolean = this.timeseriesTableWidgetSettingsForm.get('useRowStyleFunction').value; const displayPagination: boolean = this.timeseriesTableWidgetSettingsForm.get('displayPagination').value; if (useRowStyleFunction) { - this.timeseriesTableWidgetSettingsForm.get('rowStyleFunction').enable(); + this.timeseriesTableWidgetSettingsForm.get('rowStyleFunction').enable({emitEvent}); } else { - this.timeseriesTableWidgetSettingsForm.get('rowStyleFunction').disable(); + this.timeseriesTableWidgetSettingsForm.get('rowStyleFunction').disable({emitEvent}); } if (displayPagination) { - this.timeseriesTableWidgetSettingsForm.get('defaultPageSize').enable(); + this.timeseriesTableWidgetSettingsForm.get('defaultPageSize').enable({emitEvent}); + this.timeseriesTableWidgetSettingsForm.get('pageStepCount').enable({emitEvent: false}); + this.timeseriesTableWidgetSettingsForm.get('pageStepIncrement').enable({emitEvent: false}); } else { - this.timeseriesTableWidgetSettingsForm.get('defaultPageSize').disable(); + this.timeseriesTableWidgetSettingsForm.get('defaultPageSize').disable({emitEvent}); + this.timeseriesTableWidgetSettingsForm.get('pageStepCount').disable({emitEvent: false}); + this.timeseriesTableWidgetSettingsForm.get('pageStepIncrement').disable({emitEvent: false}); } - this.timeseriesTableWidgetSettingsForm.get('rowStyleFunction').updateValueAndValidity({emitEvent}); - this.timeseriesTableWidgetSettingsForm.get('defaultPageSize').updateValueAndValidity({emitEvent}); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.ts index 1241a76e0d..daaa8c5efe 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.ts @@ -32,7 +32,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { merge } from 'rxjs'; import { formatValue, isDefinedAndNotNull } from '@core/utils'; -import { DataKeyConfigComponent } from '@home/components/widget/config/data-key-config.component'; +import { DataKeyConfigComponent } from '@home/components/widget/lib/settings/common/key/data-key-config.component'; import { chartLabelPositions, chartLabelPositionTranslations, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/action-settings-button.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/action-settings-button.component.html index 67707c58fa..6f1123aa40 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/action-settings-button.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/action-settings-button.component.html @@ -16,6 +16,7 @@ -->
    + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.component.ts index c5ae5c3192..52064fdf91 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.component.ts @@ -27,7 +27,7 @@ import { WidgetActionType, WidgetMobileActionDescriptor, WidgetMobileActionType, - widgetMobileActionTypeTranslationMap + widgetMobileActionTypeTranslationMap, } from '@shared/models/widget.models'; import { CustomActionEditorCompleter } from '@home/components/widget/lib/settings/common/action/custom-action.models'; import { @@ -38,7 +38,8 @@ import { getDefaultProcessImageFunction, getDefaultProcessLaunchResultFunction, getDefaultProcessLocationFunction, - getDefaultProcessQrCodeFunction + getDefaultProcessQrCodeFunction, + getDefaultProvisionSuccessFunction } from '@home/components/widget/lib/settings/common/action/mobile-action-editor.models'; import { WidgetService } from '@core/http/widget.service'; import { TbFunction } from '@shared/models/js-function.models'; @@ -254,6 +255,18 @@ export class MobileActionEditorComponent implements ControlValueAccessor, OnInit this.fb.control(processLocationFunction, [Validators.required]) ); break; + case WidgetMobileActionType.deviceProvision: + let handleProvisionSuccessFunction = action?.handleProvisionSuccessFunction; + if (changed) { + const defaultProvisionSuccessFunction = getDefaultProvisionSuccessFunction(); + if (defaultProvisionSuccessFunction !== handleProvisionSuccessFunction) { + handleProvisionSuccessFunction = defaultProvisionSuccessFunction; + } + } + this.mobileActionTypeFormGroup.addControl( + 'handleProvisionSuccessFunction', + this.fb.control(handleProvisionSuccessFunction, [Validators.required]) + ); } } this.mobileActionTypeFormGroup.valueChanges.pipe( diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.models.ts index 24a84c2093..0d4c46c589 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.models.ts @@ -138,6 +138,18 @@ const processLocationFunction: TbFunction = ' }, 100);\n' + '}'; +const provisionSuccessFunction: TbFunction = + '// Function body to handle device provision success. \n' + + '// - deviceName - name of device that was successfully provisioned.\n' + + '\n' + + 'showDeviceProvisionSuccess(deviceName);\n' + + '\n' + + 'function showDeviceProvisionSuccess(deviceName) {\n' + + ' setTimeout(function() {\n' + + ' widgetContext.showSuccessToast(`Device ` + deviceName + ` was successfully provisioned`).subscribe();\n' + + ' }, 100);\n' + + '}\n'; + const handleEmptyResultFunctionTemplate: TbFunction = '// Optional function body to handle empty result. \n' + '// Usually this happens when user cancels the action (for ex. by pressing phone back button). \n\n' + @@ -145,7 +157,7 @@ const handleEmptyResultFunctionTemplate: TbFunction = '\n' + 'function showEmptyResultDialog(message) {\n' + ' setTimeout(function() {\n' + - ' widgetContext.dialogs.alert(\'Empty result\', message).subscribe();\n' + + ' widgetContext.showInfoToast(message).subscribe();\n' + ' }, 100);\n' + '}\n'; @@ -241,6 +253,8 @@ export const getDefaultProcessQrCodeFunction = () => processQrCodeFunction; export const getDefaultProcessLocationFunction = () => processLocationFunction; +export const getDefaultProvisionSuccessFunction = () => provisionSuccessFunction; + export const getDefaultGetLocationFunction = () => getLocationFunctionTemplate; export const getDefaultGetPhoneNumberFunction = () => getPhoneNumberFunctionTemplate; @@ -272,6 +286,9 @@ export const getDefaultHandleEmptyResultFunction = (type: WidgetMobileActionType case WidgetMobileActionType.takeScreenshot: message = 'Take screenshot action was cancelled!'; break; + case WidgetMobileActionType.deviceProvision: + message = 'Device provision was not invoked!'; + break; } return handleEmptyResultFunctionTemplate.replace('--MESSAGE--', message); }; @@ -303,6 +320,9 @@ export const getDefaultHandleErrorFunction = (type: WidgetMobileActionType): TbF case WidgetMobileActionType.takeScreenshot: title = 'Failed to take screenshot'; break; + case WidgetMobileActionType.deviceProvision: + title = 'Failed to make device provision'; + break; } return handleErrorFunctionTemplate.replace('--TITLE--', title); }; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html index c89be83849..8a5684801e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html @@ -21,7 +21,10 @@ + [widgetType]="widgetType" + [withName]="withName" + [actionNames]="actionNames" + [additionalWidgetActionTypes]="additionalWidgetActionTypes">
    @@ -36,7 +39,7 @@ type="button" (click)="applyWidgetAction()" [disabled]="widgetActionFormGroup.invalid || !widgetActionFormGroup.dirty"> - {{ 'action.apply' | translate }} + {{ applyTitle }}
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.ts index 7b6cb6ee3c..a9befcf16d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.ts @@ -15,25 +15,12 @@ /// import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; -import { PageComponent } from '@shared/components/page.component'; import { TbPopoverComponent } from '@shared/components/popover.component'; -import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; -import { Store } from '@ngrx/store'; -import { AppState } from '@core/core.state'; -import { merge } from 'rxjs'; -import { - DataToValueType, - GetValueAction, - getValueActions, - getValueActionTranslations, - GetValueSettings -} from '@shared/models/action-widget-settings.models'; -import { ValueType } from '@shared/models/constants'; -import { TargetDevice, WidgetAction, widgetType } from '@shared/models/widget.models'; -import { AttributeScope, DataKeyType, telemetryTypeTranslationsShort } from '@shared/models/telemetry/telemetry.models'; -import { IAliasController } from '@core/api/widget-api.models'; -import { WidgetService } from '@core/http/widget.service'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { WidgetAction, WidgetActionType, widgetType } from '@shared/models/widget.models'; import { WidgetActionCallbacks } from '@home/components/widget/action/manage-widget-actions.component.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { TranslateService } from '@ngx-translate/core'; @Component({ selector: 'tb-widget-action-settings-panel', @@ -42,7 +29,7 @@ import { WidgetActionCallbacks } from '@home/components/widget/action/manage-wid styleUrls: ['./action-settings-panel.component.scss'], encapsulation: ViewEncapsulation.None }) -export class WidgetActionSettingsPanelComponent extends PageComponent implements OnInit { +export class WidgetActionSettingsPanelComponent implements OnInit { @Input() widgetAction: WidgetAction; @@ -57,7 +44,17 @@ export class WidgetActionSettingsPanelComponent extends PageComponent implements callbacks: WidgetActionCallbacks; @Input() - popover: TbPopoverComponent; + @coerceBoolean() + withName = false; + + @Input() + actionNames: string[]; + + @Input() + applyTitle = this.translate.instant('action.apply'); + + @Input() + additionalWidgetActionTypes: WidgetActionType[]; @Output() widgetActionApplied = new EventEmitter(); @@ -65,8 +62,8 @@ export class WidgetActionSettingsPanelComponent extends PageComponent implements widgetActionFormGroup: UntypedFormGroup; constructor(private fb: UntypedFormBuilder, - protected store: Store) { - super(store); + private translate: TranslateService, + private popover: TbPopoverComponent) { } ngOnInit(): void { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings.component.ts index f4b767df28..7731df6407 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings.component.ts @@ -29,7 +29,12 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; import { TranslateService } from '@ngx-translate/core'; -import { WidgetAction, widgetActionTypeTranslationMap, widgetType } from '@shared/models/widget.models'; +import { + WidgetAction, + WidgetActionType, + widgetActionTypeTranslationMap, + widgetType +} from '@shared/models/widget.models'; import { WidgetActionCallbacks } from '@home/components/widget/action/manage-widget-actions.component.models'; import { WidgetActionSettingsPanelComponent @@ -65,6 +70,9 @@ export class WidgetActionSettingsComponent implements OnInit, ControlValueAccess @Input() disabled = false; + @Input() + additionalWidgetActionTypes: WidgetActionType[]; + modelValue: WidgetAction; displayValue: string; @@ -110,7 +118,8 @@ export class WidgetActionSettingsComponent implements OnInit, ControlValueAccess widgetAction: this.modelValue, panelTitle: this.panelTitle, widgetType: this.widgetType, - callbacks: this.callbacks + callbacks: this.callbacks, + additionalWidgetActionTypes: this.additionalWidgetActionTypes }; const widgetActionSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, this.viewContainerRef, WidgetActionSettingsPanelComponent, @@ -118,7 +127,6 @@ export class WidgetActionSettingsComponent implements OnInit, ControlValueAccess ctx, {}, {}, {}, true); - widgetActionSettingsPanelPopover.tbComponentRef.instance.popover = widgetActionSettingsPanelPopover; widgetActionSettingsPanelPopover.tbComponentRef.instance.widgetActionApplied.subscribe((widgetAction) => { widgetActionSettingsPanelPopover.hide(); this.modelValue = widgetAction; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html index 948fd947a6..ca2405b8b4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html @@ -16,6 +16,23 @@ -->
    +
    +
    {{'widget-config.action-name' | translate}}*
    + + + + warning + + +
    widget-config.action
    @@ -241,7 +258,21 @@ helpId="widget/action/custom_action_fn" > - + +
    +
    {{ 'widget-action.map-item-type' | translate }}
    + + + @for(type of mapItemTypes; track type) { + {{ mapItemTypeTranslationMap.get(type) | translate }} + } + + +
    +
    + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts index f6dd52002b..932c0e52c3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts @@ -16,16 +16,20 @@ import { ControlValueAccessor, + FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validator, + ValidatorFn, Validators } from '@angular/forms'; import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; import { + MapItemType, + mapItemTypeTranslationMap, WidgetAction, WidgetActionType, widgetActionTypes, @@ -46,6 +50,7 @@ import { CustomActionEditorCompleter, toCustomAction } from '@home/components/widget/lib/settings/common/action/custom-action.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; const stateDisplayTypes = ['normal', 'separateDialog', 'popover'] as const; type stateDisplayTypeTuple = typeof stateDisplayTypes; @@ -89,10 +94,32 @@ export class WidgetActionComponent implements ControlValueAccessor, OnInit, Vali @Input() callbacks: WidgetActionCallbacks; + @Input() + @coerceBoolean() + withName = false; + + @Input() + actionNames: string[]; + + @Input() + set additionalWidgetActionTypes(value: WidgetActionType[]) { + if (this.widgetActionFormGroup && !widgetActionTypes.includes(this.widgetActionFormGroup.get('type').value)) { + this.widgetActionFormGroup.get('type').setValue(WidgetActionType.doNothing); + } + if (value?.length) { + this.widgetActionTypes = widgetActionTypes.concat(value); + } else { + this.widgetActionTypes = widgetActionTypes; + } + } + widgetActionTypes = widgetActionTypes; widgetActionTypeTranslations = widgetActionTypeTranslationMap; widgetActionType = WidgetActionType; + mapItemTypes = Object.values(MapItemType) as MapItemType[]; + mapItemTypeTranslationMap = mapItemTypeTranslationMap; + allStateDisplayTypes = stateDisplayTypes; allPopoverPlacements = PopoverPlacements; @@ -147,6 +174,10 @@ export class WidgetActionComponent implements ControlValueAccessor, OnInit, Vali ngOnInit() { this.widgetActionFormGroup = this.fb.group({}); + if (this.withName) { + this.widgetActionFormGroup.addControl('name', + this.fb.control(null, [this.validateActionName(), Validators.required])); + } this.widgetActionFormGroup.addControl('type', this.fb.control(null, [Validators.required])); this.widgetActionFormGroup.get('type').valueChanges.pipe( @@ -159,9 +190,17 @@ export class WidgetActionComponent implements ControlValueAccessor, OnInit, Vali ).subscribe(() => { this.widgetActionUpdated(); }); + if (this.additionalWidgetActionTypes) { + this.widgetActionTypes = this.widgetActionTypes.concat(this.additionalWidgetActionTypes); + } } writeValue(widgetAction?: WidgetAction): void { + if (this.withName) { + this.widgetActionFormGroup.patchValue({ + name: widgetAction?.name + }, {emitEvent: false}); + } this.widgetActionFormGroup.patchValue({ type: widgetAction?.type }, {emitEvent: false}); @@ -290,6 +329,16 @@ export class WidgetActionComponent implements ControlValueAccessor, OnInit, Vali this.fb.control(action ? action.url : null, [Validators.required]) ); break; + case WidgetActionType.placeMapItem: + this.actionTypeFormGroup.addControl( + 'mapItemType', + this.fb.control(action?.mapItemType ?? MapItemType.marker, [Validators.required]) + ); + this.actionTypeFormGroup.addControl( + 'customAction', + this.fb.control(toCustomAction(action), [Validators.required]) + ); + break; } } this.actionTypeFormGroupSubscriptions.push( @@ -457,11 +506,35 @@ export class WidgetActionComponent implements ControlValueAccessor, OnInit, Vali return res; } + private validateActionName(): ValidatorFn { + return (c: FormControl) => { + const newName = c.value; + const valid = this.checkActionName(newName); + return !valid ? { + actionNameNotUnique: true + } : null; + }; + } + + private checkActionName(name: string): boolean { + let actionNameIsUnique = true; + if (this.actionNames?.length) { + actionNameIsUnique = !this.actionNames.includes(name); + } + return actionNameIsUnique; + } + private widgetActionUpdated() { const type: WidgetActionType = this.widgetActionFormGroup.get('type').value; let result: WidgetAction; if (type === WidgetActionType.customPretty) { result = {...this.widgetActionFormGroup.value, ...this.actionTypeFormGroup.get('customAction').value}; + } else if (type === WidgetActionType.placeMapItem) { + result = { + ...this.widgetActionFormGroup.value, + ...this.actionTypeFormGroup.get('customAction').value, + mapItemType: this.actionTypeFormGroup.get('mapItemType').value + }; } else { result = {...this.widgetActionFormGroup.value, ...this.actionTypeFormGroup.value}; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/advanced-range.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/advanced-range.component.ts index e0b301e1ad..45197bea55 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/advanced-range.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/advanced-range.component.ts @@ -27,7 +27,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { IAliasController } from '@core/api/widget-api.models'; import { AdvancedColorRange } from '@shared/models/widget-settings.models'; -import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { Datasource } from '@shared/models/widget.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-alias-select.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.html similarity index 56% rename from ui-ngx/src/app/modules/home/components/alias/entity-alias-select.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.html index 54a2abbfd6..f2a2ee4b9c 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-alias-select.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.html @@ -15,9 +15,14 @@ limitations under the License. --> - - {{ 'entity.entity-alias' | translate }} - + {{ 'entity.entity-alias' | translate }} + - - - + + + warning + +
    +
    + + {{ requiredText | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.scss similarity index 70% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.scss rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.scss index 3e47506dbc..4646d1a5be 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.scss @@ -14,7 +14,7 @@ * limitations under the License. */ .tb-data-key-input { - .mat-mdc-form-field.tb-inline-field.tb-key-field { + .mat-mdc-form-field.tb-key-field { width: 100%; &.mat-form-field-appearance-fill { .mdc-text-field--filled.mdc-text-field--disabled { @@ -23,14 +23,25 @@ } } } - .mat-mdc-text-field-wrapper:not(.mdc-text-field--outlined) { - padding-left: 8px; - padding-right: 0; - + .mat-mdc-text-field-wrapper { + &:not(.mdc-text-field--outlined) { + padding-left: 8px; + padding-right: 0; + .mat-mdc-form-field-infix { + padding-top: 0; + padding-bottom: 6px; + } + } + &.mdc-text-field--outlined { + .mat-mdc-form-field-infix { + padding-top: 12px; + padding-bottom: 12px; + } + .mdc-evolution-chip-set__chips { + margin-left: 0; + } + } .mat-mdc-form-field-infix { - padding-top: 0; - padding-bottom: 6px; - .mdc-evolution-chip-set .mdc-evolution-chip { margin: 0; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.ts similarity index 96% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.ts index 71685a182d..30020aec80 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.ts @@ -50,13 +50,14 @@ import { UtilsService } from '@core/services/utils.service'; import { alarmFields } from '@shared/models/alarm.models'; import { filter, map, mergeMap, publishReplay, refCount, share, tap } from 'rxjs/operators'; import { AggregationType } from '@shared/models/time/time.models'; -import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeysCallbacks } from './data-keys.component.models'; import { IAliasController } from '@core/api/widget-api.models'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; @Component({ selector: 'tb-data-key-input', templateUrl: './data-key-input.component.html', - styleUrls: ['./data-key-input.component.scss', '../../../config/data-keys.component.scss'], + styleUrls: ['./data-key-input.component.scss', './data-keys.component.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, @@ -83,6 +84,19 @@ export class DataKeyInputComponent implements ControlValueAccessor, OnInit, OnCh @Input() disabled: boolean; + @Input() + label: string; + + @Input() + appearance: MatFormFieldAppearance = 'fill'; + + @Input() + subscriptSizing: SubscriptSizing = 'fixed'; + + @Input() + @coerceBoolean() + inlineField = true; + @Input() @coerceBoolean() required = false; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.html similarity index 91% rename from ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.html index ec74941cdb..6188371288 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.html @@ -15,8 +15,12 @@ limitations under the License. --> - - {{placeholder}} + + {{ label ? label : placeholder}}
    drag_indicator
    -
    +
    + + warning + - + {{ requiredText }} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.models.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.models.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.models.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.scss similarity index 81% rename from ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.scss rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.scss index 8b377a8456..67db0b94de 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.scss @@ -13,14 +13,38 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +.tb-data-keys { + .mat-mdc-form-field.tb-keys-field { + width: 100%; + .mat-mdc-text-field-wrapper { + &.mdc-text-field--outlined { + .mat-mdc-form-field-infix { + padding-top: 12px; + padding-bottom: 12px; + .mdc-evolution-chip-set .mdc-evolution-chip { + margin: 0; + } + .mdc-evolution-chip-set__chips { + margin-left: 0; + } + input.mat-mdc-chip-input { + height: 32px; + margin-left: 0; + } + } + .tb-datakeys-container { + gap: 8px; + } + } + } + } +} + .tb-datakeys-container { display: flex; flex-wrap: wrap; width: 100%; - - input.tb-dragging { - display: none; - } } .mat-mdc-chip.mat-mdc-standard-chip.tb-datakey-chip { @@ -60,6 +84,9 @@ background: rgba(0, 0, 0, 0.04); border-radius: 100px; padding: 2px 10px; + &.tb-transparent { + background: transparent; + } .tb-chip-label { font-weight: normal; font-size: 14px; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.ts similarity index 95% rename from ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.ts index ca827e47a1..e60d0298d9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.ts @@ -19,7 +19,7 @@ import { Component, DestroyRef, ElementRef, - forwardRef, + forwardRef, HostBinding, Input, OnChanges, OnInit, @@ -60,19 +60,19 @@ import { MatDialog } from '@angular/material/dialog'; import { DataKeyConfigDialogComponent, DataKeyConfigDialogData -} from '@home/components/widget/config/data-key-config-dialog.component'; +} from './data-key-config-dialog.component'; import { deepClone, guid, isDefinedAndNotNull, isObject, isUndefined } from '@core/utils'; import { Dashboard } from '@shared/models/dashboard.models'; import { AggregationType } from '@shared/models/time/time.models'; import { DndDropEvent } from 'ngx-drag-drop/lib/dnd-dropzone.directive'; import { moveItemInArray } from '@angular/cdk/drag-drop'; import { coerceBoolean } from '@shared/decorators/coercion'; -import { DatasourceComponent } from '@home/components/widget/config/datasource.component'; import { ColorPickerPanelComponent } from '@shared/components/color-picker/color-picker-panel.component'; import { TbPopoverService } from '@shared/components/popover.service'; import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormProperty } from '@shared/models/dynamic-form.models'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; @Component({ selector: 'tb-data-keys', @@ -98,21 +98,41 @@ import { FormProperty } from '@shared/models/dynamic-form.models'; }) export class DataKeysComponent implements ControlValueAccessor, OnInit, OnChanges, ErrorStateMatcher, Validator { - public get hideDataKeyLabel(): boolean { - return this.datasourceComponent.hideDataKeyLabel; - } + @HostBinding('class') + hostClass = 'tb-data-keys'; - public get hideDataKeyColor(): boolean { - return this.datasourceComponent.hideDataKeyColor; - } + @Input() + label: string; - public get hideDataKeyUnits(): boolean { - return this.datasourceComponent.hideDataKeyUnits; - } + @Input() + appearance: MatFormFieldAppearance = 'fill'; - public get hideDataKeyDecimals(): boolean { - return this.datasourceComponent.hideDataKeyDecimals; - } + @Input() + subscriptSizing: SubscriptSizing = 'fixed'; + + @Input() + @coerceBoolean() + inlineField = false; + + @Input() + @coerceBoolean() + hideDataKeyLabel: boolean; + + @Input() + @coerceBoolean() + hideDataKeyColor: boolean; + + @Input() + @coerceBoolean() + hideDataKeyUnits: boolean; + + @Input() + @coerceBoolean() + hideDataKeyDecimals: boolean; + + @Input() + @coerceBoolean() + disableDrag = false; widgetTypes = widgetType; dataKeyTypes = DataKeyType; @@ -138,6 +158,7 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, OnChange } @Input() + @coerceBoolean() optDataKeys: boolean; @Input() @@ -175,6 +196,9 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, OnChange @Input() deviceId: string; + @Input() + generateKey: (key: DataKey) => DataKey; + private requiredValue: boolean; get required(): boolean { return this.requiredValue || !this.optDataKeys || this.isCountDatasource; @@ -222,7 +246,6 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, OnChange private keysValidator = this._keysValidator.bind(this); constructor(@SkipSelf() private errorStateMatcher: ErrorStateMatcher, - private datasourceComponent: DatasourceComponent, private translate: TranslateService, private utils: UtilsService, private dialog: MatDialog, @@ -463,8 +486,13 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, OnChange } private addFromChipValue(chip: DataKey) { - const key = this.callbacks.generateDataKey(chip.name, chip.type, this.dataKeySettingsForm, this.latestDataKeys, - this.datakeySettingsFunction); + let key: DataKey; + if (this.generateKey) { + key = this.generateKey(chip); + } else { + key = this.callbacks.generateDataKey(chip.name, chip.type, this.dataKeySettingsForm, this.latestDataKeys, + this.datakeySettingsFunction); + } this.addKey(key); } @@ -687,7 +715,7 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, OnChange } get dragDisabled(): boolean { - return this.keys.length < 2; + return this.keys.length < 2 || this.disableDrag; } get maxDataKeysSet(): boolean { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.html new file mode 100644 index 0000000000..51cbc572a5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.html @@ -0,0 +1,101 @@ + +
    +
    widgets.maps.data-layer.color-settings
    +
    + + + {{ 'widgets.maps.data-layer.color-type-constant' | translate }} + + + {{ 'widgets.maps.data-layer.color-type-range' | translate }} + + + {{ 'widgets.maps.data-layer.color-type-function' | translate }} + + +
    +
    +
    widgets.maps.data-layer.color
    + + +
    +
    +
    +
    + + +
    +
    widgets.maps.data-layer.color-range
    + + +
    +
    +
    + +
    +
    + + + +
    +
    + + +
    + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.scss new file mode 100644 index 0000000000..47ab07639b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.scss @@ -0,0 +1,65 @@ +/** + * 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-data-layer-color-settings-panel { + --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); + --mat-form-field-trailing-icon-color: rgba(0,0,0,0.38); + width: 700px; + max-width: 90vw; + min-height: 300px; + max-height: 90vh; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-xs} { + width: 90vw; + } + .tb-data-layer-color-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-data-layer-color-settings-panel-body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + } + .tb-data-layer-color-settings-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } + .tb-color-ranges-panel { + flex: 1; + min-height: 0; + gap: 16px; + display: flex; + flex-direction: column; + .tb-color-ranges { + --mat-icon-color: rgba(0, 0, 0, 0.38); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts new file mode 100644 index 0000000000..895fb3e782 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts @@ -0,0 +1,129 @@ +/// +/// 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 { PageComponent } from '@shared/components/page.component'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { WidgetService } from '@core/http/widget.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { DataLayerColorSettings, DataLayerColorType, MapType } from '@shared/models/widget/maps/map.models'; +import { DataKey, DatasourceType, widgetType } from '@shared/models/widget.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; + +@Component({ + selector: 'tb-data-layer-color-settings-panel', + templateUrl: './data-layer-color-settings-panel.component.html', + providers: [], + styleUrls: ['./data-layer-color-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class DataLayerColorSettingsPanelComponent extends PageComponent implements OnInit { + + widgetType = widgetType; + + DataKeyType = DataKeyType; + + @Input() + colorSettings: DataLayerColorSettings; + + @Input() + context: MapSettingsContext; + + @Input() + dsType: DatasourceType; + + @Input() + dsEntityAliasId: string; + + @Input() + dsDeviceId: string; + + @Input() + helpId = 'widget/lib/map/color_fn'; + + @Input() + popover: TbPopoverComponent; + + @Output() + colorSettingsApplied = new EventEmitter(); + + DataLayerColorType = DataLayerColorType; + + colorSettingsFormGroup: UntypedFormGroup; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService, + protected store: Store, + private destroyRef: DestroyRef) { + super(store); + } + + ngOnInit(): void { + this.colorSettingsFormGroup = this.fb.group( + { + type: [this.colorSettings?.type || DataLayerColorType.constant, []], + color: [this.colorSettings?.color, []], + rangeKey: [this.colorSettings?.rangeKey, [Validators.required]], + range: [this.colorSettings?.range, []], + colorFunction: [this.colorSettings?.colorFunction, []] + } + ); + this.colorSettingsFormGroup.get('type').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateValidators(); + setTimeout(() => {this.popover?.updatePosition();}, 0); + }); + this.updateValidators(); + } + + cancel() { + this.popover?.hide(); + } + + applyColorSettings() { + const colorSettings: DataLayerColorSettings = this.colorSettingsFormGroup.value; + this.colorSettingsApplied.emit(colorSettings); + } + + public editRangeKey() { + const targetDataKey: DataKey = this.colorSettingsFormGroup.get('rangeKey').value; + this.context.editKey(targetDataKey, + this.dsDeviceId, this.dsEntityAliasId, widgetType.latest).subscribe( + (updatedDataKey) => { + if (updatedDataKey) { + this.colorSettingsFormGroup.get('rangeKey').patchValue(updatedDataKey); + this.colorSettingsFormGroup.markAsDirty(); + } + } + ); + } + + private updateValidators() { + const type: DataLayerColorType = this.colorSettingsFormGroup.get('type').value; + if (type === DataLayerColorType.range) { + this.colorSettingsFormGroup.get('rangeKey').enable({emitEvent: false}); + } else { + this.colorSettingsFormGroup.get('rangeKey').disable({emitEvent: false}); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.html new file mode 100644 index 0000000000..81abfd4a7d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.html @@ -0,0 +1,30 @@ + + + +
    +
    +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts new file mode 100644 index 0000000000..5f56bd85c3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts @@ -0,0 +1,147 @@ +/// +/// 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, forwardRef, Input, Renderer2, ViewContainerRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ColorType, ComponentStyle } from '@shared/models/widget-settings.models'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { DataLayerColorSettings, DataLayerColorType } from '@shared/models/widget/maps/map.models'; +import { + DataLayerColorSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; +import { DatasourceType } from '@shared/models/widget.models'; + +@Component({ + selector: 'tb-data-layer-color-settings', + templateUrl: './data-layer-color-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DataLayerColorSettingsComponent), + multi: true + } + ] +}) +export class DataLayerColorSettingsComponent implements ControlValueAccessor { + + @Input() + disabled: boolean; + + @Input() + context: MapSettingsContext; + + @Input() + dsType: DatasourceType; + + @Input() + dsEntityAliasId: string; + + @Input() + dsDeviceId: string; + + @Input() + helpId = 'widget/lib/map/color_fn'; + + DataLayerColorType = DataLayerColorType; + + modelValue: DataLayerColorSettings; + + colorStyle: ComponentStyle = {}; + + private propagateChange: (v: any) => void = () => { }; + + constructor(private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef) {} + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + this.updateColorStyle(); + } + + writeValue(value: DataLayerColorSettings): void { + if (value) { + this.modelValue = value; + this.updateColorStyle(); + } + } + + openColorSettingsPopup($event: Event, matButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const ctx: any = { + colorSettings: this.modelValue, + context: this.context, + dsType: this.dsType, + dsEntityAliasId: this.dsEntityAliasId, + dsDeviceId: this.dsDeviceId, + helpId: this.helpId + }; + const colorSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, DataLayerColorSettingsPanelComponent, 'left', true, null, + ctx, + {}, + {}, {}, true); + colorSettingsPanelPopover.tbComponentRef.instance.popover = colorSettingsPanelPopover; + colorSettingsPanelPopover.tbComponentRef.instance.colorSettingsApplied.subscribe((colorSettings) => { + colorSettingsPanelPopover.hide(); + this.modelValue = colorSettings; + this.updateColorStyle(); + this.propagateChange(this.modelValue); + }); + } + } + + private updateColorStyle() { + if (!this.disabled && this.modelValue && this.modelValue.type !== DataLayerColorType.function) { + let colors: string[] = [this.modelValue.color]; + const rangeList = this.modelValue.range; + if (this.modelValue.type === DataLayerColorType.range && rangeList?.length) { + const rangeColors = rangeList.slice(0, Math.min(2, rangeList.length)).map(r => r.color); + colors = colors.concat(rangeColors); + } + if (colors.length === 1) { + this.colorStyle = {backgroundColor: colors[0]}; + } else { + const gradientValues: string[] = []; + const step = 100 / colors.length; + for (let i = 0; i < colors.length; i++) { + gradientValues.push(`${colors[i]} ${step*i}%`); + gradientValues.push(`${colors[i]} ${step*(i+1)}%`); + } + this.colorStyle = {background: `linear-gradient(90deg, ${gradientValues.join(', ')})`}; + } + } else { + this.colorStyle = {}; + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html new file mode 100644 index 0000000000..93ec63c128 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html @@ -0,0 +1,94 @@ + + +
    + + + +
    + + {{ patternTitle ? patternTitle : ((patternType === 'label' ? 'widgets.maps.data-layer.label' : 'widgets.maps.data-layer.tooltip') | translate) }} + + + {{ 'widgets.maps.data-layer.pattern-type-pattern' | translate }} + {{ 'widgets.maps.data-layer.pattern-type-function' | translate }} + +
    +
    +
    + + + + + + +
    +
    widgets.maps.data-layer.tooltip-trigger
    + + + + {{ dataLayerTooltipTriggerTranslationMap.get(trigger) | translate }} + + + +
    +
    + + {{ 'widgets.maps.data-layer.auto-close-tooltips' | translate }} + +
    +
    +
    widgets.maps.data-layer.tooltip-offset
    +
    +
    +
    widgets.maps.data-layer.tooltip-offset-horizontal
    + + + +
    +
    +
    widgets.maps.data-layer.tooltip-offset-vertical
    + + + +
    +
    +
    + + +
    +
    +
    +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts new file mode 100644 index 0000000000..681b93da4a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts @@ -0,0 +1,195 @@ +/// +/// 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, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + Validators +} from '@angular/forms'; +import { merge } from 'rxjs'; +import { WidgetService } from '@core/http/widget.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + DataLayerPatternSettings, + DataLayerPatternType, + DataLayerTooltipSettings, dataLayerTooltipTriggers, dataLayerTooltipTriggerTranslationMap +} from '@shared/models/widget/maps/map.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; + +@Component({ + selector: 'tb-data-layer-pattern-settings', + templateUrl: './data-layer-pattern-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DataLayerPatternSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DataLayerPatternSettingsComponent), + multi: true + } + ] +}) +export class DataLayerPatternSettingsComponent implements OnInit, ControlValueAccessor, Validator { + + DataLayerPatternType = DataLayerPatternType; + + dataLayerTooltipTriggers = dataLayerTooltipTriggers; + + dataLayerTooltipTriggerTranslationMap = dataLayerTooltipTriggerTranslationMap; + + settingsExpanded = false; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + @Input() + disabled: boolean; + + @Input() + patternType: 'label' | 'tooltip' = 'label'; + + @Input() + helpId = 'widget/lib/map/label_fn'; + + @Input() + patternTitle: string; + + @Input() + @coerceBoolean() + hasTooltipOffset = false; + + @Input() + @coerceBoolean() + expand = true; + + @Input() + context: MapSettingsContext; + + private modelValue: DataLayerPatternSettings | DataLayerTooltipSettings; + + private propagateChange = null; + + public patternSettingsFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + + this.patternSettingsFormGroup = this.fb.group({ + show: [null, []], + type: [null, []], + pattern: [null, [Validators.required]], + patternFunction: [null, [Validators.required]] + }); + if (this.patternType === 'tooltip') { + this.patternSettingsFormGroup.addControl('trigger', this.fb.control(null, [])); + this.patternSettingsFormGroup.addControl('autoclose', this.fb.control(null, [])); + if (this.hasTooltipOffset) { + this.patternSettingsFormGroup.addControl('offsetX', this.fb.control(null, [])); + this.patternSettingsFormGroup.addControl('offsetY', this.fb.control(null, [])); + } + this.patternSettingsFormGroup.addControl('tagActions', this.fb.control(null, [])); + } + this.patternSettingsFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + merge(this.patternSettingsFormGroup.get('show').valueChanges, + this.patternSettingsFormGroup.get('type').valueChanges + ).pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateValidators(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.patternSettingsFormGroup.disable({emitEvent: false}); + } else { + this.patternSettingsFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: DataLayerPatternSettings | DataLayerTooltipSettings): void { + this.modelValue = value; + this.patternSettingsFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updateValidators(); + this.settingsExpanded = this.patternSettingsFormGroup.get('show').value && this.expand; + this.patternSettingsFormGroup.get('show').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((show) => { + this.settingsExpanded = show; + }); + } + + public validate(c: UntypedFormControl) { + const valid = this.patternSettingsFormGroup.valid; + return valid ? null : { + [this.patternType]: { + valid: false, + }, + }; + } + + private updateValidators() { + const show: boolean = this.patternSettingsFormGroup.get('show').value; + const type: DataLayerPatternType = this.patternSettingsFormGroup.get('type').value; + if (show) { + this.patternSettingsFormGroup.enable({emitEvent: false}); + if (type === DataLayerPatternType.pattern) { + this.patternSettingsFormGroup.get('pattern').enable({emitEvent: false}); + this.patternSettingsFormGroup.get('patternFunction').disable({emitEvent: false}); + } else { + this.patternSettingsFormGroup.get('pattern').disable({emitEvent: false}); + this.patternSettingsFormGroup.get('patternFunction').enable({emitEvent: false}); + } + } else { + this.patternSettingsFormGroup.disable({emitEvent: false}); + this.patternSettingsFormGroup.get('show').enable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = this.patternSettingsFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.html new file mode 100644 index 0000000000..fc1ba06ed8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.html @@ -0,0 +1,62 @@ + +
    +
    +
    + {{ 'widgets.maps.image.image-source' | translate }} +
    + + {{ 'widgets.maps.image.image-source-image' | translate }} + {{ 'widgets.maps.image.image-source-entity-key' | translate }} + +
    + + + +
    +
    widgets.maps.image.source-entity-alias
    + + +
    +
    +
    widgets.maps.image.image-url-key
    + + +
    +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.ts new file mode 100644 index 0000000000..5b43386664 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.ts @@ -0,0 +1,157 @@ +/// +/// 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, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + Validators +} from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ImageMapSourceSettings, ImageSourceType } from '@shared/models/widget/maps/map.models'; +import { DataKey, DatasourceType, widgetType } from '@shared/models/widget.models'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; + +@Component({ + selector: 'tb-image-map-source-settings', + templateUrl: './image-map-source-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ImageMapSourceSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ImageMapSourceSettingsComponent), + multi: true + } + ] +}) +export class ImageMapSourceSettingsComponent implements OnInit, ControlValueAccessor, Validator { + + ImageSourceType = ImageSourceType; + DatasourceType = DatasourceType; + widgetType = widgetType; + DataKeyType = DataKeyType; + + @Input() + disabled: boolean; + + @Input() + context: MapSettingsContext; + + private modelValue: ImageMapSourceSettings; + + private propagateChange = null; + + public imageMapSourceFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + this.imageMapSourceFormGroup = this.fb.group({ + sourceType: [null, [Validators.required]], + url: [null, [Validators.required]], + entityAliasId: [null, [Validators.required]], + entityKey: [null, [Validators.required]] + }); + this.imageMapSourceFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + this.imageMapSourceFormGroup.get('sourceType').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateValidators(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.imageMapSourceFormGroup.disable({emitEvent: false}); + } else { + this.imageMapSourceFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: ImageMapSourceSettings): void { + this.modelValue = value; + this.imageMapSourceFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updateValidators(); + } + + editKey() { + const entityKey: DataKey = this.imageMapSourceFormGroup.get('entityKey').value; + this.context.editKey(entityKey, + null, this.imageMapSourceFormGroup.get('entityAliasId').value).subscribe( + (updatedDataKey) => { + if (updatedDataKey) { + this.imageMapSourceFormGroup.get('entityKey').patchValue(updatedDataKey); + } + } + ); + } + + public validate(c: UntypedFormControl) { + const valid = this.imageMapSourceFormGroup.valid; + return valid ? null : { + imageMapSource: { + valid: false, + }, + }; + } + + + private updateValidators() { + const sourceType: ImageSourceType = this.imageMapSourceFormGroup.get('sourceType').value; + if (sourceType === ImageSourceType.image) { + this.imageMapSourceFormGroup.get('url').enable({emitEvent: false}); + this.imageMapSourceFormGroup.get('entityAliasId').disable({emitEvent: false}); + this.imageMapSourceFormGroup.get('entityKey').disable({emitEvent: false}); + } else { + this.imageMapSourceFormGroup.get('url').disable({emitEvent: false}); + this.imageMapSourceFormGroup.get('entityAliasId').enable({emitEvent: false}); + this.imageMapSourceFormGroup.get('entityKey').enable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = this.imageMapSourceFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.html new file mode 100644 index 0000000000..8bd57487b0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.html @@ -0,0 +1,47 @@ + +
    + + + + warning + + + + + + +
    + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.ts new file mode 100644 index 0000000000..466c6a39d6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.ts @@ -0,0 +1,100 @@ +/// +/// 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, EventEmitter, forwardRef, Output } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator +} from '@angular/forms'; +import { MapActionButtonSettings } from '@shared/models/widget/maps/map.models'; +import { WidgetAction, WidgetActionType, widgetType } from '@shared/models/widget.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { isEmptyStr } from '@core/utils'; + +@Component({ + selector: 'tb-map-action-button-row', + templateUrl: 'map-action-button-row.component.html', + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapActionButtonRowComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapActionButtonRowComponent), + multi: true + }] +}) +export class MapActionButtonRowComponent implements ControlValueAccessor, Validator { + + @Output() + buttonRemoved = new EventEmitter(); + + mapActionButton = this.fb.group({ + label: [''], + icon: [''], + color: [''], + action: this.fb.control(null) + }, {validators: this.validateButtonConfig()}); + + additionalWidgetActionTypes = [WidgetActionType.placeMapItem]; + readonly widgetType = widgetType; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: FormBuilder) { + this.mapActionButton.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe(value => this.propagateChange(value)) + } + + registerOnChange(fn: any) { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { } + + setDisabledState(isDisabled: boolean) { + if (isDisabled) { + this.mapActionButton.disable({emitEvent: false}); + } else { + this.mapActionButton.enable({emitEvent: false}); + } + } + + validate(): ValidationErrors | null { + return this.mapActionButton.valid ? null : { + mapButtonAction: false + }; + } + + writeValue(value: MapActionButtonSettings) { + this.mapActionButton.patchValue(value, {emitEvent: false}); + } + + private validateButtonConfig() { + return (c: FormGroup) => { + return !c.value.icon && isEmptyStr(c.value.label) ? { + invalidButtonConfig: true + } : null; + }; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.html new file mode 100644 index 0000000000..b0ad6737d4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.html @@ -0,0 +1,63 @@ + +
    + + + {{ 'widgets.maps.map-action.map-action-buttons' | translate }} + + +
    +
    +
    widgets.maps.map-action.label
    +
    widgets.maps.map-action.icon
    +
    widgets.maps.map-action.color
    +
    widgets.maps.map-action.action
    +
    +
    +
    + @for (mapAction of mapActionButtonsForm.controls; track mapAction) { +
    + + +
    + +
    +
    + } @empty { + {{ 'widgets.maps.map-action.no-action-buttons-configured' | translate }} + } +
    +
    + +
    +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.ts new file mode 100644 index 0000000000..0260676e03 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.ts @@ -0,0 +1,107 @@ +/// +/// 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, forwardRef } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator +} from '@angular/forms'; +import { + defaultMapActionButtonSettings, + MapActionButtonSettings +} from '@shared/models/widget/maps/map.models'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +@Component({ + selector: 'tb-map-action-button-settings', + templateUrl: './map-action-buttons-settings.component.html', + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapActionButtonsSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapActionButtonsSettingsComponent), + multi: true + }] +}) +export class MapActionButtonsSettingsComponent implements ControlValueAccessor, Validator { + + mapActionButtonsForm = this.fb.array([]); + + private propagateChange = (_val: any) => {}; + + constructor(private fb: FormBuilder) { + this.mapActionButtonsForm.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe(value => this.propagateChange(value)); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.mapActionButtonsForm.disable({emitEvent: false}); + } else { + this.mapActionButtonsForm.enable({emitEvent: false}); + } + } + + validate(): ValidationErrors | null { + return this.mapActionButtonsForm.valid ? null : { + mapActionButtons: false + }; + } + + writeValue(buttons: MapActionButtonSettings[] = []) { + if (buttons?.length === this.mapActionButtonsForm.length) { + this.mapActionButtonsForm.patchValue(buttons, {emitEvent: false}); + } else { + this.mapActionButtonsForm.clear({emitEvent: false}); + buttons.forEach( + button => this.mapActionButtonsForm.push(this.fb.control(button), {emitEvent: false}) + ); + } + } + + get dragEnabled(): boolean { + return this.mapActionButtonsForm.length > 1; + } + + buttonDrop(event: CdkDragDrop) { + const actionButton = this.mapActionButtonsForm.at(event.previousIndex); + this.mapActionButtonsForm.removeAt(event.previousIndex, {emitEvent: false}); + this.mapActionButtonsForm.insert(event.currentIndex, actionButton); + } + + addButton() { + this.mapActionButtonsForm.push(this.fb.control(defaultMapActionButtonSettings)); + } + + removeButton(index: number) { + this.mapActionButtonsForm.removeAt(index); + } +} 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 new file mode 100644 index 0000000000..803cdd01f1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html @@ -0,0 +1,524 @@ + +
    + +

    {{dialogTitle}}

    + + +
    +
    +
    +
    +
    {{ 'widget-config.datasource' | translate }}
    + + {{ datasourceTypesTranslations.get(type) | translate }} + +
    +
    + + datasource.label + + + + + + + + +
    +
    +
    +
    {{ 'datakey.keys' | translate }}
    +
    +
    + + + + +
    + + + + + + +
    +
    +
    +
    {{ 'widget-config.appearance' | translate }}
    + +
    + + + +
    + @if (dataLayerType === 'markers') { +
    {{ 'widgets.maps.data-layer.marker.marker' | translate }}
    + } @else { + +
    {{ 'widgets.maps.data-layer.marker.marker' | translate }}
    +
    + } + + {{ 'widgets.maps.data-layer.marker.marker-type-shape' | translate }} + {{ 'widgets.maps.data-layer.marker.marker-type-icon' | translate }} + {{ 'widgets.maps.data-layer.marker.marker-type-image' | translate }} + +
    +
    +
    + +
    +
    widgets.maps.data-layer.marker.shape
    + +
    +
    +
    widgets.maps.data-layer.marker.icon
    + +
    +
    +
    widgets.maps.data-layer.marker.image
    + +
    +
    +
    widgets.maps.data-layer.marker.marker-offset
    +
    +
    widgets.maps.data-layer.marker.offset-horizontal
    + + + +
    widgets.maps.data-layer.marker.offset-vertical
    + + + +
    +
    +
    + + {{ 'widgets.maps.data-layer.marker.rotate-marker' | translate }} + +
    +
    widgets.maps.data-layer.marker.offset-angle
    + + +
    deg
    +
    +
    +
    + @if (dataLayerType === 'trips') { + + + } +
    +
    +
    +
    +
    widgets.maps.data-layer.marker.position-conversion
    + + +
    +
    + +
    + + + + +
    {{ 'widgets.maps.data-layer.path.path' | translate }}
    +
    +
    +
    + +
    +
    widgets.maps.data-layer.path.path
    +
    + + + px + + +
    +
    +
    + + + + + {{ 'widgets.maps.data-layer.path.path-decorator' | translate }} + + + + +
    +
    widgets.maps.data-layer.path.decorator-symbol
    +
    + + + + {{ pathDecoratorSymbolTranslationMap.get(symbol) | translate }} + + + +
    + + + px + + + +
    +
    +
    +
    +
    widgets.maps.data-layer.path.decorator-arrangement
    +
    +
    +
    widgets.maps.data-layer.path.decorator-offset
    + + +
    px
    +
    +
    +
    +
    widgets.maps.data-layer.path.decorator-end-offset
    + + +
    px
    +
    +
    +
    +
    widgets.maps.data-layer.path.decorator-repeat
    + + +
    px
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + +
    {{ 'widgets.maps.data-layer.points.points' | translate }}
    +
    +
    +
    + +
    +
    widgets.maps.data-layer.points.points
    +
    + + + px + + +
    +
    + + +
    +
    +
    +
    + +
    +
    widgets.maps.data-layer.fill-color
    + +
    +
    +
    widgets.maps.data-layer.stroke
    +
    + + + px + + +
    +
    +
    + @if (dataLayerType !== 'trips') { + + } +
    + @if (dataLayerType !== 'trips') { + + } +
    +
    {{ 'widgets.maps.data-layer.groups' | translate }}
    + + +
    +
    +
    {{ dataLayerEditTitle | translate }}
    +
    +
    widgets.maps.data-layer.edit-instruments
    + + + {{ dataLayerEditActionTranslationMap.get(action) | translate }} + + +
    +
    +
    widgets.maps.data-layer.persist-location-attribute-scope
    + + + {{ telemetryTypeTranslationsShort.get(AttributeScope.SERVER_SCOPE) | translate }} + + + {{ telemetryTypeTranslationsShort.get(AttributeScope.SHARED_SCOPE) | translate }} + + +
    +
    + + {{ 'widgets.maps.data-layer.enable-snapping' | translate }} + +
    +
    + + +
    +
    + + +
    +
    + + + + + + + + + + + + +
    +
    widgets.maps.data-layer.behavior
    +
    +
    widgets.maps.data-layer.on-click
    + + +
    +
    +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.scss new file mode 100644 index 0000000000..37899cda7d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.scss @@ -0,0 +1,41 @@ +/** + * 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. + */ +.tb-map-data-layer-dialog { + --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); + --mat-form-field-trailing-icon-color: rgba(0,0,0,0.38); + .tb-inline-chips { + --mat-form-field-container-vertical-padding: 12px; + .mat-mdc-text-field-wrapper { + &.mdc-text-field--outlined { + padding-left: 16px; + padding-right: 16px; + .mat-mdc-form-field-infix { + .mdc-evolution-chip-set .mdc-evolution-chip { + margin: 0; + } + .mdc-evolution-chip-set__chips { + gap: 8px; + margin-left: 0; + } + input.mat-mdc-chip-input { + height: 32px; + margin-left: 0; + } + } + } + } + } +} 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 new file mode 100644 index 0000000000..dc0123406a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts @@ -0,0 +1,513 @@ +/// +/// 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, Inject, ViewEncapsulation } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { + CirclesDataLayerSettings, + DataLayerEditAction, + dataLayerEditActions, + dataLayerEditActionTranslationMap, + defaultBaseMapDataLayerSettings, + MapDataLayerSettings, + MapDataLayerType, + MapType, + MarkersDataLayerSettings, + MarkerType, + pathDecoratorSymbols, + pathDecoratorSymbolTranslationMap, + PolygonsDataLayerSettings, + ShapeDataLayerSettings, + TripsDataLayerSettings +} from '@shared/models/widget/maps/map.models'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { FormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { DataKey, DatasourceType, datasourceTypeTranslationMap, widgetType } from '@shared/models/widget.models'; +import { AttributeScope, DataKeyType, telemetryTypeTranslationsShort } from '@shared/models/telemetry/telemetry.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EntityType } from '@shared/models/entity-type.models'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; +import { genNextLabelForDataKeys, mergeDeepIgnoreArray } from '@core/utils'; +import { WidgetService } from '@core/http/widget.service'; +import { merge } from 'rxjs'; + +export interface MapDataLayerDialogData { + settings: MapDataLayerSettings; + mapType: MapType; + dataLayerType: MapDataLayerType; + context: MapSettingsContext; +} + +@Component({ + selector: 'tb-map-data-layer-dialog', + templateUrl: './map-data-layer-dialog.component.html', + styleUrls: ['./map-data-layer-dialog.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class MapDataLayerDialogComponent extends DialogComponent { + + DatasourceType = DatasourceType; + + EntityType = EntityType; + + MapType = MapType; + + DataKeyType = DataKeyType; + + AttributeScope = AttributeScope; + telemetryTypeTranslationsShort = telemetryTypeTranslationsShort; + + widgetType = widgetType; + + MarkerType = MarkerType; + + datasourceTypes: Array = []; + datasourceTypesTranslations = datasourceTypeTranslationMap; + + dataLayerEditTitle: string; + dataLayerEditActions: Array = []; + dataLayerEditActionTranslationMap = dataLayerEditActionTranslationMap; + + pathDecoratorSymbols = pathDecoratorSymbols; + pathDecoratorSymbolTranslationMap = pathDecoratorSymbolTranslationMap; + + dataLayerFormGroup: UntypedFormGroup; + + settings = this.data.settings; + mapType = this.data.mapType; + dataLayerType = this.data.dataLayerType; + context = this.data.context; + + generateAdditionalDataKey = this.generateDataKey.bind(this); + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + dialogTitle: string; + + get labelHelpId(): string { + switch (this.dataLayerType) { + case 'trips': + return 'widget/lib/map/label_fn'; + case 'markers': + return 'widget/lib/map/label_fn'; + case 'polygons': + return 'widget/lib/map/polygon_label_fn'; + case 'circles': + return 'widget/lib/map/circle_label_fn'; + default: + return 'widget/lib/map/label_fn'; + } + } + + get tooltipHelpId(): string { + switch (this.dataLayerType) { + case 'trips': + return 'widget/lib/map/tooltip_fn'; + case 'markers': + return 'widget/lib/map/tooltip_fn'; + case 'polygons': + return 'widget/lib/map/polygon_tooltip_fn'; + case 'circles': + return 'widget/lib/map/circle_tooltip_fn'; + default: + return 'widget/lib/map/tooltip_fn'; + } + } + + get showEditAttributeScope(): boolean { + if (this.dataLayerType !== 'trips') { + const editEnabledActions: DataLayerEditAction[] = + this.dataLayerFormGroup.get('edit').get('enabledActions').value; + if (editEnabledActions && editEnabledActions.length) { + switch (this.dataLayerType) { + case 'markers': + const xKey: DataKey = this.dataLayerFormGroup.get('xKey').value; + const yKey: DataKey = this.dataLayerFormGroup.get('yKey').value; + return (xKey?.type === DataKeyType.attribute || yKey?.type === DataKeyType.attribute); + case 'polygons': + const polygonKey: DataKey = this.dataLayerFormGroup.get('polygonKey').value; + return polygonKey?.type === DataKeyType.attribute; + case 'circles': + const circleKey: DataKey = this.dataLayerFormGroup.get('circleKey').value; + return circleKey?.type === DataKeyType.attribute; + } + } else { + return false; + } + } else { + return false; + } + } + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: MapDataLayerDialogData, + public dialogRef: MatDialogRef, + private fb: FormBuilder, + private widgetService: WidgetService, + private destroyRef: DestroyRef) { + super(store, router, dialogRef); + + if (this.context.functionsOnly) { + this.datasourceTypes = [DatasourceType.function]; + } else { + this.datasourceTypes = [DatasourceType.function, DatasourceType.device, DatasourceType.entity]; + } + + this.settings = mergeDeepIgnoreArray({} as MapDataLayerSettings, + defaultBaseMapDataLayerSettings(this.mapType, this.dataLayerType), this.settings); + + this.dataLayerFormGroup = this.fb.group({ + dsType: [this.settings.dsType, [Validators.required]], + dsLabel: [this.settings.dsLabel, []], + dsDeviceId: [this.settings.dsDeviceId, [Validators.required]], + dsEntityAliasId: [this.settings.dsEntityAliasId, [Validators.required]], + dsFilterId: [this.settings.dsFilterId, []], + additionalDataKeys: [this.settings.additionalDataKeys, []], + label: [this.settings.label, []], + tooltip: [this.settings.tooltip, []], + click: [this.settings.click, []], + groups: [this.settings.groups, []] + }); + + if (this.dataLayerType !== 'trips') { + this.dataLayerFormGroup.addControl('edit', this.fb.group({ + enabledActions: [this.settings.edit?.enabledActions, []], + attributeScope: [this.settings.edit?.attributeScope, []], + snappable: [this.settings.edit?.snappable, []] + })); + } + + switch (this.dataLayerType) { + case 'trips': + this.dialogTitle = 'widgets.maps.data-layer.trip.trip-configuration'; + const tripsDataLayer = this.settings as TripsDataLayerSettings; + this.dataLayerFormGroup.addControl('xKey', this.fb.control(tripsDataLayer.xKey, Validators.required)); + this.dataLayerFormGroup.addControl('yKey', this.fb.control(tripsDataLayer.yKey, Validators.required)); + this.dataLayerFormGroup.addControl('showMarker', this.fb.control(tripsDataLayer.showMarker)); + this.dataLayerFormGroup.addControl('markerType', this.fb.control(tripsDataLayer.markerType, Validators.required)); + this.dataLayerFormGroup.addControl('markerShape', this.fb.control(tripsDataLayer.markerShape, Validators.required)); + this.dataLayerFormGroup.addControl('markerIcon', this.fb.control(tripsDataLayer.markerIcon, Validators.required)); + this.dataLayerFormGroup.addControl('markerImage', this.fb.control(tripsDataLayer.markerImage, Validators.required)); + this.dataLayerFormGroup.addControl('markerOffsetX', this.fb.control(tripsDataLayer.markerOffsetX)); + this.dataLayerFormGroup.addControl('markerOffsetY', this.fb.control(tripsDataLayer.markerOffsetY)); + this.dataLayerFormGroup.addControl('rotateMarker', this.fb.control(tripsDataLayer.rotateMarker)); + this.dataLayerFormGroup.addControl('offsetAngle', this.fb.control(tripsDataLayer.offsetAngle, [Validators.min(0), Validators.max(360)])); + if (this.mapType === MapType.image) { + this.dataLayerFormGroup.addControl('positionFunction', this.fb.control(tripsDataLayer.positionFunction)); + } + this.dataLayerFormGroup.addControl('showPath', this.fb.control(tripsDataLayer.showPath)); + this.dataLayerFormGroup.addControl('pathStrokeWeight', this.fb.control(tripsDataLayer.pathStrokeWeight, [Validators.required, Validators.min(0)])); + this.dataLayerFormGroup.addControl('pathStrokeColor', this.fb.control(tripsDataLayer.pathStrokeColor, Validators.required)); + this.dataLayerFormGroup.addControl('usePathDecorator', this.fb.control(tripsDataLayer.usePathDecorator)); + this.dataLayerFormGroup.addControl('pathDecoratorSymbol', this.fb.control(tripsDataLayer.pathDecoratorSymbol, Validators.required)); + this.dataLayerFormGroup.addControl('pathDecoratorSymbolSize', this.fb.control(tripsDataLayer.pathDecoratorSymbolSize, [Validators.required, Validators.min(0)])); + this.dataLayerFormGroup.addControl('pathDecoratorSymbolColor', this.fb.control(tripsDataLayer.pathDecoratorSymbolColor)); + this.dataLayerFormGroup.addControl('pathDecoratorOffset', this.fb.control(tripsDataLayer.pathDecoratorOffset, [Validators.required, Validators.min(0)])); + this.dataLayerFormGroup.addControl('pathEndDecoratorOffset', this.fb.control(tripsDataLayer.pathEndDecoratorOffset, [Validators.required, Validators.min(0)])); + this.dataLayerFormGroup.addControl('pathDecoratorRepeat', this.fb.control(tripsDataLayer.pathDecoratorRepeat, [Validators.required, Validators.min(0)])); + this.dataLayerFormGroup.addControl('showPoints', this.fb.control(tripsDataLayer.showPoints)); + this.dataLayerFormGroup.addControl('pointSize', this.fb.control(tripsDataLayer.pointSize, [Validators.required, Validators.min(0)])); + this.dataLayerFormGroup.addControl('pointColor', this.fb.control(tripsDataLayer.pointColor, Validators.required)); + this.dataLayerFormGroup.addControl('pointTooltip', this.fb.control(tripsDataLayer.pointTooltip)); + merge(this.dataLayerFormGroup.get('showMarker').valueChanges, + this.dataLayerFormGroup.get('markerType').valueChanges, + this.dataLayerFormGroup.get('rotateMarker').valueChanges, + this.dataLayerFormGroup.get('showPath').valueChanges, + this.dataLayerFormGroup.get('usePathDecorator').valueChanges, + this.dataLayerFormGroup.get('showPoints').valueChanges).pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => + this.updateValidators() + ); + break; + case 'markers': + this.dialogTitle = 'widgets.maps.data-layer.marker.marker-configuration'; + this.dataLayerEditTitle = 'widgets.maps.data-layer.marker.edit'; + this.dataLayerEditActions = [DataLayerEditAction.add, DataLayerEditAction.move, DataLayerEditAction.remove]; + const markersDataLayer = this.settings as MarkersDataLayerSettings; + this.dataLayerFormGroup.addControl('xKey', this.fb.control(markersDataLayer.xKey, Validators.required)); + this.dataLayerFormGroup.addControl('yKey', this.fb.control(markersDataLayer.yKey, Validators.required)) + this.dataLayerFormGroup.addControl('markerType', this.fb.control(markersDataLayer.markerType, Validators.required)); + this.dataLayerFormGroup.addControl('markerShape', this.fb.control(markersDataLayer.markerShape, Validators.required)); + this.dataLayerFormGroup.addControl('markerIcon', this.fb.control(markersDataLayer.markerIcon, Validators.required)); + this.dataLayerFormGroup.addControl('markerImage', this.fb.control(markersDataLayer.markerImage, Validators.required)); + this.dataLayerFormGroup.addControl('markerOffsetX', this.fb.control(markersDataLayer.markerOffsetX)); + this.dataLayerFormGroup.addControl('markerOffsetY', this.fb.control(markersDataLayer.markerOffsetY)); + if (this.mapType === MapType.image) { + this.dataLayerFormGroup.addControl('positionFunction', this.fb.control(markersDataLayer.positionFunction)); + } + this.dataLayerFormGroup.addControl('markerClustering', this.fb.control(markersDataLayer.markerClustering)); + this.dataLayerFormGroup.get('markerType').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => + this.updateValidators() + ); + break; + case 'polygons': + case 'circles': + this.dataLayerEditActions = dataLayerEditActions; + const shapeDataLayer = this.settings as ShapeDataLayerSettings; + this.dataLayerFormGroup.addControl('fillColor', this.fb.control(shapeDataLayer.fillColor, 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') { + this.dialogTitle = 'widgets.maps.data-layer.polygon.polygon-configuration'; + this.dataLayerEditTitle = 'widgets.maps.data-layer.polygon.edit'; + const polygonsDataLayer = this.settings as PolygonsDataLayerSettings; + this.dataLayerFormGroup.addControl('polygonKey', this.fb.control(polygonsDataLayer.polygonKey, Validators.required)); + } else { + this.dialogTitle = 'widgets.maps.data-layer.circle.circle-configuration'; + this.dataLayerEditTitle = 'widgets.maps.data-layer.circle.edit'; + const circlesDataLayer = this.settings as CirclesDataLayerSettings; + this.dataLayerFormGroup.addControl('circleKey', this.fb.control(circlesDataLayer.circleKey, Validators.required)); + } + break; + } + this.dataLayerFormGroup.get('dsType').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + (newDsType: DatasourceType) => this.onDsTypeChanged(newDsType) + ); + this.updateValidators(); + } + + private onDsTypeChanged(newDsType: DatasourceType) { + switch (this.dataLayerType) { + case 'trips': + case 'markers': + const xKey: DataKey = this.dataLayerFormGroup.get('xKey').value; + if (this.updateDataKeyToNewDsType(xKey, newDsType, this.dataLayerType === 'trips')) { + this.dataLayerFormGroup.get('xKey').patchValue(xKey, {emitEvent: false}); + } + const yKey: DataKey = this.dataLayerFormGroup.get('yKey').value; + if (this.updateDataKeyToNewDsType(yKey, newDsType, this.dataLayerType === 'trips')) { + this.dataLayerFormGroup.get('yKey').patchValue(yKey, {emitEvent: false}); + } + break; + case 'polygons': + const polygonKey: DataKey = this.dataLayerFormGroup.get('polygonKey').value; + if (this.updateDataKeyToNewDsType(polygonKey, newDsType)) { + this.dataLayerFormGroup.get('polygonKey').patchValue(polygonKey, {emitEvent: false}); + } + break; + case 'circles': + const circleKey: DataKey = this.dataLayerFormGroup.get('circleKey').value; + if (this.updateDataKeyToNewDsType(circleKey, newDsType)) { + this.dataLayerFormGroup.get('circleKey').patchValue(circleKey, {emitEvent: false}); + } + break; + } + const additionalDataKeys: DataKey[] = this.dataLayerFormGroup.get('additionalDataKeys').value; + if (additionalDataKeys?.length) { + let updated = false; + for (const key of additionalDataKeys) { + updated = this.updateDataKeyToNewDsType(key, newDsType) || updated; + } + if (updated) { + this.dataLayerFormGroup.get('additionalDataKeys').patchValue(additionalDataKeys, {emitEvent: false}); + } + } + this.updateValidators(); + } + + private updateDataKeyToNewDsType(dataKey: DataKey, newDsType: DatasourceType, timeSeries = false): boolean { + if (newDsType === DatasourceType.function) { + if (dataKey.type !== DataKeyType.function) { + dataKey.type = DataKeyType.function; + return true; + } + } else { + if (dataKey.type === DataKeyType.function) { + dataKey.type = timeSeries ? DataKeyType.timeseries : DataKeyType.attribute; + return true; + } + } + return false; + } + + private updateValidators() { + const dsType: DatasourceType = this.dataLayerFormGroup.get('dsType').value; + if (dsType === DatasourceType.function) { + this.dataLayerFormGroup.get('dsLabel').enable({emitEvent: false}); + this.dataLayerFormGroup.get('dsDeviceId').disable({emitEvent: false}); + this.dataLayerFormGroup.get('dsEntityAliasId').disable({emitEvent: false}); + } else if (dsType === DatasourceType.device) { + this.dataLayerFormGroup.get('dsLabel').disable({emitEvent: false}); + this.dataLayerFormGroup.get('dsDeviceId').enable({emitEvent: false}); + this.dataLayerFormGroup.get('dsEntityAliasId').disable({emitEvent: false}); + } else { + this.dataLayerFormGroup.get('dsLabel').disable({emitEvent: false}); + this.dataLayerFormGroup.get('dsDeviceId').disable({emitEvent: false}); + this.dataLayerFormGroup.get('dsEntityAliasId').enable({emitEvent: false}); + } + if (this.dataLayerType === 'markers') { + this.updateMarkerTypeValidators(); + } + if (this.dataLayerType === 'trips') { + const showMarker: boolean = this.dataLayerFormGroup.get('showMarker').value; + if (showMarker) { + this.dataLayerFormGroup.get('markerType').enable({emitEvent: false}); + this.updateMarkerTypeValidators(); + this.dataLayerFormGroup.get('markerOffsetX').enable({emitEvent: false}); + this.dataLayerFormGroup.get('markerOffsetY').enable({emitEvent: false}); + this.dataLayerFormGroup.get('rotateMarker').enable({emitEvent: false}); + const rotateMarker: boolean = this.dataLayerFormGroup.get('rotateMarker').value; + if (rotateMarker) { + this.dataLayerFormGroup.get('offsetAngle').enable({emitEvent: false}); + } else { + this.dataLayerFormGroup.get('offsetAngle').disable({emitEvent: false}); + } + this.dataLayerFormGroup.get('label').enable({emitEvent: false}); + this.dataLayerFormGroup.get('tooltip').enable({emitEvent: false}); + this.dataLayerFormGroup.get('click').enable({emitEvent: false}); + } else { + this.dataLayerFormGroup.get('markerType').disable({emitEvent: false}); + this.dataLayerFormGroup.get('markerShape').disable({emitEvent: false}); + this.dataLayerFormGroup.get('markerIcon').disable({emitEvent: false}); + this.dataLayerFormGroup.get('markerImage').disable({emitEvent: false}); + this.dataLayerFormGroup.get('markerOffsetX').disable({emitEvent: false}); + this.dataLayerFormGroup.get('markerOffsetY').disable({emitEvent: false}); + this.dataLayerFormGroup.get('rotateMarker').disable({emitEvent: false}); + this.dataLayerFormGroup.get('offsetAngle').disable({emitEvent: false}); + this.dataLayerFormGroup.get('label').disable({emitEvent: false}); + this.dataLayerFormGroup.get('tooltip').disable({emitEvent: false}); + this.dataLayerFormGroup.get('click').disable({emitEvent: false}); + } + const showPath: boolean = this.dataLayerFormGroup.get('showPath').value; + const usePathDecorator: boolean = this.dataLayerFormGroup.get('usePathDecorator').value; + const showPoints: boolean = this.dataLayerFormGroup.get('showPoints').value; + if (showPath) { + this.dataLayerFormGroup.get('pathStrokeWeight').enable({emitEvent: false}); + this.dataLayerFormGroup.get('pathStrokeColor').enable({emitEvent: false}); + this.dataLayerFormGroup.get('usePathDecorator').enable({emitEvent: false}); + if (usePathDecorator) { + this.dataLayerFormGroup.get('pathDecoratorSymbol').enable({emitEvent: false}); + this.dataLayerFormGroup.get('pathDecoratorSymbolSize').enable({emitEvent: false}); + this.dataLayerFormGroup.get('pathDecoratorSymbolColor').enable({emitEvent: false}); + this.dataLayerFormGroup.get('pathDecoratorOffset').enable({emitEvent: false}); + this.dataLayerFormGroup.get('pathEndDecoratorOffset').enable({emitEvent: false}); + this.dataLayerFormGroup.get('pathDecoratorRepeat').enable({emitEvent: false}); + } else { + this.dataLayerFormGroup.get('pathDecoratorSymbol').disable({emitEvent: false}); + this.dataLayerFormGroup.get('pathDecoratorSymbolSize').disable({emitEvent: false}); + this.dataLayerFormGroup.get('pathDecoratorSymbolColor').disable({emitEvent: false}); + this.dataLayerFormGroup.get('pathDecoratorOffset').disable({emitEvent: false}); + this.dataLayerFormGroup.get('pathEndDecoratorOffset').disable({emitEvent: false}); + this.dataLayerFormGroup.get('pathDecoratorRepeat').disable({emitEvent: false}); + } + } else { + this.dataLayerFormGroup.get('pathStrokeWeight').disable({emitEvent: false}); + this.dataLayerFormGroup.get('pathStrokeColor').disable({emitEvent: false}); + this.dataLayerFormGroup.get('usePathDecorator').disable({emitEvent: false}); + this.dataLayerFormGroup.get('pathDecoratorSymbol').disable({emitEvent: false}); + this.dataLayerFormGroup.get('pathDecoratorSymbolSize').disable({emitEvent: false}); + this.dataLayerFormGroup.get('pathDecoratorSymbolColor').disable({emitEvent: false}); + this.dataLayerFormGroup.get('pathDecoratorOffset').disable({emitEvent: false}); + this.dataLayerFormGroup.get('pathEndDecoratorOffset').disable({emitEvent: false}); + this.dataLayerFormGroup.get('pathDecoratorRepeat').disable({emitEvent: false}); + } + if (showPoints) { + this.dataLayerFormGroup.get('pointSize').enable({emitEvent: false}); + this.dataLayerFormGroup.get('pointColor').enable({emitEvent: false}); + this.dataLayerFormGroup.get('pointTooltip').enable({emitEvent: false}); + } else { + this.dataLayerFormGroup.get('pointSize').disable({emitEvent: false}); + this.dataLayerFormGroup.get('pointColor').disable({emitEvent: false}); + this.dataLayerFormGroup.get('pointTooltip').disable({emitEvent: false}); + } + } + } + + private updateMarkerTypeValidators(): void { + const markerType: MarkerType = this.dataLayerFormGroup.get('markerType').value; + if (markerType === MarkerType.shape) { + this.dataLayerFormGroup.get('markerShape').enable({emitEvent: false}); + this.dataLayerFormGroup.get('markerIcon').disable({emitEvent: false}); + this.dataLayerFormGroup.get('markerImage').disable({emitEvent: false}); + } else if (markerType === MarkerType.icon) { + this.dataLayerFormGroup.get('markerShape').disable({emitEvent: false}); + this.dataLayerFormGroup.get('markerIcon').enable({emitEvent: false}); + this.dataLayerFormGroup.get('markerImage').disable({emitEvent: false}); + } else { + this.dataLayerFormGroup.get('markerShape').disable({emitEvent: false}); + this.dataLayerFormGroup.get('markerIcon').disable({emitEvent: false}); + this.dataLayerFormGroup.get('markerImage').enable({emitEvent: false}); + } + } + + editKey(keyType: 'xKey' | 'yKey' | 'polygonKey' | 'circleKey') { + const targetDataKey: DataKey = this.dataLayerFormGroup.get(keyType).value; + this.context.editKey(targetDataKey, + this.dataLayerFormGroup.get('dsDeviceId').value, this.dataLayerFormGroup.get('dsEntityAliasId').value, + this.dataLayerType === 'trips' ? widgetType.timeseries : widgetType.latest).subscribe( + (updatedDataKey) => { + if (updatedDataKey) { + this.dataLayerFormGroup.get(keyType).patchValue(updatedDataKey); + this.dataLayerFormGroup.markAsDirty(); + } + } + ); + } + + private generateDataKey(key: DataKey): DataKey { + const dataKey = this.context.callbacks.generateDataKey(key.name, key.type, null, false, null); + const dataKeys: DataKey[] = []; + switch (this.dataLayerType) { + case 'trips': + case 'markers': + const xKey: DataKey = this.dataLayerFormGroup.get('xKey').value; + if (xKey) { + dataKeys.push(xKey); + } + const yKey: DataKey = this.dataLayerFormGroup.get('yKey').value; + if (yKey) { + dataKeys.push(yKey); + } + break; + case 'polygons': + const polygonKey: DataKey = this.dataLayerFormGroup.get('polygonKey').value; + if (polygonKey) { + dataKeys.push(polygonKey); + } + break; + case 'circles': + const circleKey: DataKey = this.dataLayerFormGroup.get('circleKey').value; + if (circleKey) { + dataKeys.push(circleKey); + } + break; + } + const additionalKeys: DataKey[] = this.dataLayerFormGroup.get('additionalDataKeys').value; + if (additionalKeys) { + dataKeys.push(...additionalKeys); + } + dataKey.label = genNextLabelForDataKeys(dataKey.label, dataKeys); + return dataKey; + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + const settings: MapDataLayerSettings = this.dataLayerFormGroup.getRawValue(); + this.dialogRef.close(settings); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.html new file mode 100644 index 0000000000..b03c9ae500 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.html @@ -0,0 +1,138 @@ + +
    +
    + + + + {{ datasourceTypesTranslations.get(type) | translate }} + + + + + + + + + + +
    + + + + + + + + +
    + +
    + +
    +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.scss new file mode 100644 index 0000000000..1f17c05f82 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.scss @@ -0,0 +1,74 @@ +/** + * 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-form-table-row.tb-map-data-layer-row { + + .tb-source-field { + flex: 1 1 50%; + display: flex; + gap: 12px; + .tb-ds-type-field, .tb-label-field, .tb-device-field, .tb-entity-alias-field { + flex: 1; + } + } + + .tb-x-pos-field { + flex: 1 1 25%; + min-width: 0; + } + + .tb-y-pos-field { + flex: 1 1 25%; + min-width: 0; + } + + .tb-key-field { + flex: 1 1 50%; + min-width: 0; + } + + .tb-remove-button { + width: 40px; + min-width: 40px; + } + + @media #{$mat-lt-lg} { + .tb-source-field { + flex-direction: column; + flex: 1 1 30%; + } + .tb-x-pos-field, .tb-y-pos-field { + flex: 1 1 35%; + } + .tb-key-field { + flex: 1 1 70%; + } + } + @media screen and (min-width: 450px) and (max-width: 599px) { + .tb-source-field { + flex-direction: row; + } + } + @media #{$mat-xs} { + .tb-x-pos-field, .tb-y-pos-field { + display: none; + } + .tb-key-field { + display: none; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts new file mode 100644 index 0000000000..ae47852f33 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts @@ -0,0 +1,341 @@ +/// +/// 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, + DestroyRef, + EventEmitter, + forwardRef, + Input, + OnInit, + Output, + ViewEncapsulation +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + CirclesDataLayerSettings, + MapDataLayerSettings, + MapDataLayerType, + MapType, + MarkersDataLayerSettings, + PolygonsDataLayerSettings, + TripsDataLayerSettings +} from '@shared/models/widget/maps/map.models'; +import { DataKey, DatasourceType, datasourceTypeTranslationMap, widgetType } from '@shared/models/widget.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { MapSettingsComponent } from '@home/components/widget/lib/settings/common/map/map-settings.component'; +import { deepClone } from '@core/utils'; +import { MatDialog } from '@angular/material/dialog'; +import { + MapDataLayerDialogComponent, + MapDataLayerDialogData +} from '@home/components/widget/lib/settings/common/map/map-data-layer-dialog.component'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; + +@Component({ + selector: 'tb-map-data-layer-row', + templateUrl: './map-data-layer-row.component.html', + styleUrls: ['./map-data-layer-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapDataLayerRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class MapDataLayerRowComponent implements ControlValueAccessor, OnInit { + + DatasourceType = DatasourceType; + DataKeyType = DataKeyType; + + EntityType = EntityType; + + MapType = MapType; + + widgetType = widgetType; + + datasourceTypes: Array = []; + datasourceTypesTranslations = datasourceTypeTranslationMap; + + @Input() + disabled: boolean; + + @Input() + mapType: MapType = MapType.geoMap; + + @Input() + dataLayerType: MapDataLayerType = 'markers'; + + @Input() + context: MapSettingsContext; + + @Output() + dataLayerRemoved = new EventEmitter(); + + dataLayerFormGroup: UntypedFormGroup; + + modelValue: MapDataLayerSettings; + + editDataLayerText: string; + + removeDataLayerText: string; + + private propagateChange = (_val: any) => {}; + + constructor(private mapSettingsComponent: MapSettingsComponent, + private fb: UntypedFormBuilder, + private dialog: MatDialog, + private cd: ChangeDetectorRef, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + if (this.context.functionsOnly) { + this.datasourceTypes = [DatasourceType.function]; + } else { + this.datasourceTypes = [DatasourceType.function, DatasourceType.device, DatasourceType.entity]; + } + this.dataLayerFormGroup = this.fb.group({ + dsType: [null, [Validators.required]], + dsLabel: [null, []], + dsDeviceId: [null, [Validators.required]], + dsEntityAliasId: [null, [Validators.required]] + }); + switch (this.dataLayerType) { + case 'trips': + this.editDataLayerText = 'widgets.maps.data-layer.trip.trip-configuration'; + this.removeDataLayerText = 'widgets.maps.data-layer.trip.remove-trip'; + this.dataLayerFormGroup.addControl('xKey', this.fb.control(null, Validators.required)); + this.dataLayerFormGroup.addControl('yKey', this.fb.control(null, Validators.required)); + break; + case 'markers': + this.editDataLayerText = 'widgets.maps.data-layer.marker.marker-configuration'; + this.removeDataLayerText = 'widgets.maps.data-layer.marker.remove-marker'; + this.dataLayerFormGroup.addControl('xKey', this.fb.control(null, Validators.required)); + this.dataLayerFormGroup.addControl('yKey', this.fb.control(null, Validators.required)); + break; + case 'polygons': + this.editDataLayerText = 'widgets.maps.data-layer.polygon.polygon-configuration'; + this.removeDataLayerText = 'widgets.maps.data-layer.polygon.remove-polygon'; + this.dataLayerFormGroup.addControl('polygonKey', this.fb.control(null, Validators.required)); + break; + case 'circles': + this.editDataLayerText = 'widgets.maps.data-layer.circle.circle-configuration'; + this.removeDataLayerText = 'widgets.maps.data-layer.circle.remove-circle'; + this.dataLayerFormGroup.addControl('circleKey', this.fb.control(null, Validators.required)); + break; + } + this.dataLayerFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => this.updateModel() + ); + this.dataLayerFormGroup.get('dsType').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + (newDsType: DatasourceType) => this.onDsTypeChanged(newDsType) + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.dataLayerFormGroup.disable({emitEvent: false}); + } else { + this.dataLayerFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: MapDataLayerSettings): void { + this.modelValue = value; + this.dataLayerFormGroup.patchValue( + { + dsType: value?.dsType, + dsLabel: value?.dsLabel, + dsDeviceId: value?.dsDeviceId, + dsEntityAliasId: value?.dsEntityAliasId + }, {emitEvent: false} + ); + switch (this.dataLayerType) { + case 'trips': + const tripsDataLayer = value as TripsDataLayerSettings; + this.dataLayerFormGroup.patchValue( + { + xKey: tripsDataLayer?.xKey, + yKey: tripsDataLayer?.yKey + }, {emitEvent: false} + ); + break; + case 'markers': + const markersDataLayer = value as MarkersDataLayerSettings; + this.dataLayerFormGroup.patchValue( + { + xKey: markersDataLayer?.xKey, + yKey: markersDataLayer?.yKey + }, {emitEvent: false} + ); + break; + case 'polygons': + const polygonsDataLayer = value as PolygonsDataLayerSettings; + this.dataLayerFormGroup.patchValue( + { + polygonKey: polygonsDataLayer?.polygonKey + }, {emitEvent: false} + ); + break; + case 'circles': + const circlesDataLayer = value as CirclesDataLayerSettings; + this.dataLayerFormGroup.patchValue( + { + circleKey: circlesDataLayer?.circleKey + }, {emitEvent: false} + ); + break; + } + this.updateValidators(); + this.cd.markForCheck(); + } + + editKey(keyType: 'xKey' | 'yKey' | 'polygonKey' | 'circleKey') { + const targetDataKey: DataKey = this.dataLayerFormGroup.get(keyType).value; + this.context.editKey(targetDataKey, + this.dataLayerFormGroup.get('dsDeviceId').value, this.dataLayerFormGroup.get('dsEntityAliasId').value, + this.dataLayerType === 'trips' ? widgetType.timeseries : widgetType.latest).subscribe( + (updatedDataKey) => { + if (updatedDataKey) { + this.dataLayerFormGroup.get(keyType).patchValue(updatedDataKey); + } + } + ); + } + + editDataLayer($event: Event, matButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(MapDataLayerDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + settings: deepClone(this.modelValue), + mapType: this.mapType, + dataLayerType: this.dataLayerType, + context: this.context + } + }).afterClosed().subscribe((settings) => { + if (settings) { + this.modelValue = settings; + this.dataLayerFormGroup.patchValue(settings); + this.updateValidators(); + } + }); + } + + private onDsTypeChanged(newDsType: DatasourceType) { + let updateModel = false; + switch (this.dataLayerType) { + case 'trips': + case 'markers': + const xKey: DataKey = this.dataLayerFormGroup.get('xKey').value; + if (this.updateDataKeyToNewDsType(xKey, newDsType, this.dataLayerType === 'trips')) { + this.dataLayerFormGroup.get('xKey').patchValue(xKey, {emitEvent: false}); + updateModel = true; + } + const yKey: DataKey = this.dataLayerFormGroup.get('yKey').value; + if (this.updateDataKeyToNewDsType(yKey, newDsType, this.dataLayerType === 'trips')) { + this.dataLayerFormGroup.get('yKey').patchValue(yKey, {emitEvent: false}); + updateModel = true; + } + break; + case 'polygons': + const polygonKey: DataKey = this.dataLayerFormGroup.get('polygonKey').value; + if (this.updateDataKeyToNewDsType(polygonKey, newDsType)) { + this.dataLayerFormGroup.get('polygonKey').patchValue(polygonKey, {emitEvent: false}); + updateModel = true; + } + break; + case 'circles': + const circleKey: DataKey = this.dataLayerFormGroup.get('circleKey').value; + if (this.updateDataKeyToNewDsType(circleKey, newDsType)) { + this.dataLayerFormGroup.get('circleKey').patchValue(circleKey, {emitEvent: false}); + updateModel = true; + } + break; + } + this.updateValidators(); + if (updateModel) { + this.updateModel(); + } + } + + private updateDataKeyToNewDsType(dataKey: DataKey, newDsType: DatasourceType, timeSeries = false): boolean { + if (newDsType === DatasourceType.function) { + if (dataKey.type !== DataKeyType.function) { + dataKey.type = DataKeyType.function; + return true; + } + } else { + if (dataKey.type === DataKeyType.function) { + dataKey.type = timeSeries ? DataKeyType.timeseries : DataKeyType.attribute; + return true; + } + } + return false; + } + + private updateValidators() { + const dsType: DatasourceType = this.dataLayerFormGroup.get('dsType').value; + if (dsType === DatasourceType.function) { + this.dataLayerFormGroup.get('dsLabel').enable({emitEvent: false}); + this.dataLayerFormGroup.get('dsDeviceId').disable({emitEvent: false}); + this.dataLayerFormGroup.get('dsEntityAliasId').disable({emitEvent: false}); + } else if (dsType === DatasourceType.device) { + this.dataLayerFormGroup.get('dsLabel').disable({emitEvent: false}); + this.dataLayerFormGroup.get('dsDeviceId').enable({emitEvent: false}); + this.dataLayerFormGroup.get('dsEntityAliasId').disable({emitEvent: false}); + } else { + this.dataLayerFormGroup.get('dsLabel').disable({emitEvent: false}); + this.dataLayerFormGroup.get('dsDeviceId').disable({emitEvent: false}); + this.dataLayerFormGroup.get('dsEntityAliasId').enable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = {...this.modelValue, ...this.dataLayerFormGroup.value}; + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.html new file mode 100644 index 0000000000..46a9235970 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.html @@ -0,0 +1,52 @@ + +
    +
    +
    +
    widgets.maps.data-layer.source
    +
    + {{ (mapType === MapType.geoMap ? 'widgets.maps.data-layer.marker.latitude-key' : 'widgets.maps.data-layer.marker.x-pos-key') | translate }} +
    +
    + {{ (mapType === MapType.geoMap ? 'widgets.maps.data-layer.marker.longitude-key' : 'widgets.maps.data-layer.marker.y-pos-key') | translate }} +
    +
    widgets.maps.data-layer.polygon.polygon-key
    +
    widgets.maps.data-layer.circle.circle-key
    +
    +
    +
    +
    + + +
    +
    +
    +
    + +
    +
    + + {{ noDataLayersText | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.scss new file mode 100644 index 0000000000..84262b9810 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.scss @@ -0,0 +1,62 @@ +/** + * 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-map-data-layers { + .tb-form-table-header-cell { + &.tb-source-header { + flex: 1 1 50%; + } + &.tb-x-pos-header { + flex: 1 1 25%; + } + &.tb-y-pos-header { + flex: 1 1 25%; + } + &.tb-key-header { + flex: 1 1 50%; + } + &.tb-actions-header { + width: 80px; + min-width: 80px; + } + @media #{$mat-lt-lg} { + &.tb-source-header { + flex: 1 1 30%; + } + &.tb-x-pos-header, &.tb-y-pos-header { + flex: 1 1 35%; + } + &.tb-key-header { + flex: 1 1 70%; + } + } + @media #{$mat-xs} { + &.tb-x-pos-header, &.tb-y-pos-header { + display: none; + } + &.tb-key-header { + display: none; + } + } + } + + .tb-form-table-body { + tb-map-data-layer-row { + overflow: hidden; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts new file mode 100644 index 0000000000..747ef21681 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts @@ -0,0 +1,181 @@ +/// +/// 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, forwardRef, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator +} from '@angular/forms'; +import { mergeDeep } from '@core/utils'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + defaultMapDataLayerSettings, + MapDataLayerSettings, + MapDataLayerType, + mapDataLayerValid, + mapDataLayerValidator, + MapType +} from '@shared/models/widget/maps/map.models'; +import { MapSettingsComponent } from '@home/components/widget/lib/settings/common/map/map-settings.component'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; + +@Component({ + selector: 'tb-map-data-layers', + templateUrl: './map-data-layers.component.html', + styleUrls: ['./map-data-layers.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapDataLayersComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapDataLayersComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class MapDataLayersComponent implements ControlValueAccessor, OnInit, Validator { + + MapType = MapType; + + @Input() + disabled: boolean; + + @Input() + mapType: MapType = MapType.geoMap; + + @Input() + dataLayerType: MapDataLayerType = 'markers'; + + @Input() + context: MapSettingsContext; + + dataLayersFormGroup: UntypedFormGroup; + + addDataLayerText: string; + + noDataLayersText: string; + + private propagateChange = (_val: any) => {}; + + constructor(private mapSettingsComponent: MapSettingsComponent, + private fb: UntypedFormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + switch (this.dataLayerType) { + case 'trips': + this.addDataLayerText = 'widgets.maps.data-layer.trip.add-trip'; + this.noDataLayersText = 'widgets.maps.data-layer.trip.no-trips'; + break; + case 'markers': + this.addDataLayerText = 'widgets.maps.data-layer.marker.add-marker'; + this.noDataLayersText = 'widgets.maps.data-layer.marker.no-markers'; + break; + case 'polygons': + this.addDataLayerText = 'widgets.maps.data-layer.polygon.add-polygon'; + this.noDataLayersText = 'widgets.maps.data-layer.polygon.no-polygons'; + break; + case 'circles': + this.addDataLayerText = 'widgets.maps.data-layer.circle.add-circle'; + this.noDataLayersText = 'widgets.maps.data-layer.circle.no-circles'; + break; + } + this.dataLayersFormGroup = this.fb.group({ + dataLayers: [this.fb.array([]), []] + }); + this.dataLayersFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => { + let layers: MapDataLayerSettings[] = this.dataLayersFormGroup.get('dataLayers').value; + if (layers) { + layers = layers.filter(layer => mapDataLayerValid(layer, this.dataLayerType)); + } + this.propagateChange(layers); + } + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.dataLayersFormGroup.disable({emitEvent: false}); + } else { + this.dataLayersFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: MapDataLayerSettings[] | undefined): void { + const dataLayers: MapDataLayerSettings[] = value || []; + this.dataLayersFormGroup.setControl('dataLayers', this.prepareDataLayersFormArray(dataLayers), {emitEvent: false}); + } + + public validate(c: UntypedFormControl) { + const valid = this.dataLayersFormGroup.valid; + return valid ? null : { + dataLayers: { + valid: false, + }, + }; + } + + dataLayersFormArray(): UntypedFormArray { + return this.dataLayersFormGroup.get('dataLayers') as UntypedFormArray; + } + + trackByDataLayer(index: number, dataLayerControl: AbstractControl): any { + return dataLayerControl; + } + + removeDataLayer(index: number) { + (this.dataLayersFormGroup.get('dataLayers') as UntypedFormArray).removeAt(index); + } + + addDataLayer() { + const dataLayer = mergeDeep({} as MapDataLayerSettings, + defaultMapDataLayerSettings(this.mapType, this.dataLayerType, this.context.functionsOnly)); + const dataLayersArray = this.dataLayersFormGroup.get('dataLayers') as UntypedFormArray; + const dataLayerControl = this.fb.control(dataLayer, [mapDataLayerValidator(this.dataLayerType)]); + dataLayersArray.push(dataLayerControl); + } + + private prepareDataLayersFormArray(dataLayers: MapDataLayerSettings[]): UntypedFormArray { + const dataLayersControls: Array = []; + dataLayers.forEach((dataLayer) => { + dataLayersControls.push(this.fb.control(dataLayer, [mapDataLayerValidator(this.dataLayerType)])); + }); + return this.fb.array(dataLayersControls); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.html new file mode 100644 index 0000000000..a22e6d3cd6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.html @@ -0,0 +1,80 @@ + +
    +
    + + + + {{ datasourceTypesTranslations.get(type) | translate }} + + + + + + + + + + +
    + + +
    +
    + +
    +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.scss new file mode 100644 index 0000000000..2244f6d14f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.scss @@ -0,0 +1,56 @@ +/** + * 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-form-table-row.tb-map-data-source-row { + + .tb-source-field { + flex: 1 1 50%; + display: flex; + gap: 12px; + .tb-ds-type-field, .tb-label-field, .tb-device-field, .tb-entity-alias-field { + flex: 1; + } + } + + .tb-data-keys-field { + flex: 1 1 50%; + min-width: 0; + } + + .tb-remove-button { + width: 40px; + min-width: 40px; + } + + @media #{$mat-lt-lg} { + .tb-source-field { + flex-direction: column; + flex: 1 1 30%; + } + .tb-data-keys-field { + flex: 1 1 70%; + } + @media #{$mat-lt-md} { + .tb-source-field { + flex: 1 1 50%; + } + .tb-data-keys-field { + flex: 1 1 50%; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.ts new file mode 100644 index 0000000000..a163e8322f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.ts @@ -0,0 +1,207 @@ +/// +/// 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, + DestroyRef, + EventEmitter, + forwardRef, + Input, + OnInit, + Output, + ViewEncapsulation +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AdditionalMapDataSourceSettings } from '@shared/models/widget/maps/map.models'; +import { DataKey, DatasourceType, datasourceTypeTranslationMap, widgetType } from '@shared/models/widget.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { genNextLabelForDataKeys } from '@core/utils'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; + +@Component({ + selector: 'tb-map-data-source-row', + templateUrl: './map-data-source-row.component.html', + styleUrls: ['./map-data-source-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapDataSourceRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class MapDataSourceRowComponent implements ControlValueAccessor, OnInit { + + DatasourceType = DatasourceType; + DataKeyType = DataKeyType; + + EntityType = EntityType; + + widgetType = widgetType; + + datasourceTypes: Array = []; + datasourceTypesTranslations = datasourceTypeTranslationMap; + + @Input() + disabled: boolean; + + @Input() + context: MapSettingsContext; + + @Output() + dataSourceRemoved = new EventEmitter(); + + dataSourceFormGroup: UntypedFormGroup; + + generateAdditionalDataKey = this.generateDataKey.bind(this); + + modelValue: AdditionalMapDataSourceSettings; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private cd: ChangeDetectorRef, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + if (this.context.functionsOnly) { + this.datasourceTypes = [DatasourceType.function]; + } else { + this.datasourceTypes = [DatasourceType.function, DatasourceType.device, DatasourceType.entity]; + } + this.dataSourceFormGroup = this.fb.group({ + dsType: [null, [Validators.required]], + dsLabel: [null, []], + dsDeviceId: [null, [Validators.required]], + dsEntityAliasId: [null, [Validators.required]], + dataKeys: [null, [Validators.required]] + }); + this.dataSourceFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => this.updateModel() + ); + this.dataSourceFormGroup.get('dsType').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + (newDsType: DatasourceType) => this.onDsTypeChanged(newDsType) + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.dataSourceFormGroup.disable({emitEvent: false}); + } else { + this.dataSourceFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: AdditionalMapDataSourceSettings): void { + this.modelValue = value; + this.dataSourceFormGroup.patchValue( + { + dsType: value?.dsType, + dsLabel: value?.dsLabel, + dsDeviceId: value?.dsDeviceId, + dsEntityAliasId: value?.dsEntityAliasId, + dataKeys: value?.dataKeys + }, {emitEvent: false} + ); + this.updateValidators(); + this.cd.markForCheck(); + } + + private generateDataKey(key: DataKey): DataKey { + const dataKey = this.context.callbacks.generateDataKey(key.name, key.type, null, false, null); + const dataKeys: DataKey[] = this.dataSourceFormGroup.get('dataKeys').value || []; + dataKey.label = genNextLabelForDataKeys(dataKey.label, dataKeys); + return dataKey; + } + + private onDsTypeChanged(newDsType: DatasourceType) { + let updateModel = false; + const dataKeys: DataKey[] = this.dataSourceFormGroup.get('dataKeys').value; + if (dataKeys?.length) { + for (const key of dataKeys) { + updateModel = this.updateDataKeyToNewDsType(key, newDsType) || updateModel; + } + if (updateModel) { + this.dataSourceFormGroup.get('dataKeys').patchValue(dataKeys, {emitEvent: false}); + } + } + this.updateValidators(); + if (updateModel) { + this.updateModel(); + } + } + + private updateDataKeyToNewDsType(dataKey: DataKey, newDsType: DatasourceType): boolean { + if (newDsType === DatasourceType.function) { + if (dataKey.type !== DataKeyType.function) { + dataKey.type = DataKeyType.function; + return true; + } + } else { + if (dataKey.type === DataKeyType.function) { + dataKey.type = DataKeyType.attribute; + return true; + } + } + return false; + } + + private updateValidators() { + const dsType: DatasourceType = this.dataSourceFormGroup.get('dsType').value; + if (dsType === DatasourceType.function) { + this.dataSourceFormGroup.get('dsLabel').enable({emitEvent: false}); + this.dataSourceFormGroup.get('dsDeviceId').disable({emitEvent: false}); + this.dataSourceFormGroup.get('dsEntityAliasId').disable({emitEvent: false}); + } else if (dsType === DatasourceType.device) { + this.dataSourceFormGroup.get('dsLabel').disable({emitEvent: false}); + this.dataSourceFormGroup.get('dsDeviceId').enable({emitEvent: false}); + this.dataSourceFormGroup.get('dsEntityAliasId').disable({emitEvent: false}); + } else { + this.dataSourceFormGroup.get('dsLabel').disable({emitEvent: false}); + this.dataSourceFormGroup.get('dsDeviceId').disable({emitEvent: false}); + this.dataSourceFormGroup.get('dsEntityAliasId').enable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = {...this.modelValue, ...this.dataSourceFormGroup.value}; + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.html new file mode 100644 index 0000000000..a4fb89740b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.html @@ -0,0 +1,43 @@ + +
    +
    +
    +
    widgets.maps.data-layer.source
    +
    widgets.maps.data-layer.data-keys
    +
    +
    +
    +
    + + +
    +
    +
    +
    + +
    +
    + + {{ 'widgets.maps.data-layer.no-datasources' | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.scss new file mode 100644 index 0000000000..28788cb4f1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.scss @@ -0,0 +1,53 @@ +/** + * 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-map-data-sources { + .tb-form-table-header-cell { + &.tb-source-header { + flex: 1 1 50%; + } + &.tb-data-keys-header { + flex: 1 1 50%; + } + &.tb-actions-header { + width: 40px; + min-width: 40px; + } + @media #{$mat-lt-lg} { + &.tb-source-header { + flex: 1 1 30%; + } + &.tb-data-keys-header { + flex: 1 1 70%; + } + @media #{$mat-lt-md} { + &.tb-source-header { + flex: 1 1 50%; + } + &.tb-data-keys-header { + flex: 1 1 50%; + } + } + } + } + + .tb-form-table-body { + tb-map-data-source-row { + overflow: hidden; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.ts new file mode 100644 index 0000000000..681a135fed --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.ts @@ -0,0 +1,147 @@ +/// +/// 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, forwardRef, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator +} from '@angular/forms'; +import { mergeDeep } from '@core/utils'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + AdditionalMapDataSourceSettings, + additionalMapDataSourceValid, + additionalMapDataSourceValidator, + defaultAdditionalMapDataSourceSettings +} from '@shared/models/widget/maps/map.models'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; + +@Component({ + selector: 'tb-map-data-sources', + templateUrl: './map-data-sources.component.html', + styleUrls: ['./map-data-sources.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapDataSourcesComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapDataSourcesComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class MapDataSourcesComponent implements ControlValueAccessor, OnInit, Validator { + + @Input() + disabled: boolean; + + @Input() + context: MapSettingsContext; + + dataSourcesFormGroup: UntypedFormGroup; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + this.dataSourcesFormGroup = this.fb.group({ + dataSources: [this.fb.array([]), []] + }); + this.dataSourcesFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => { + let dataSources: AdditionalMapDataSourceSettings[] = this.dataSourcesFormGroup.get('dataSources').value; + if (dataSources) { + dataSources = dataSources.filter(dataSource => additionalMapDataSourceValid(dataSource)); + } + this.propagateChange(dataSources); + } + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.dataSourcesFormGroup.disable({emitEvent: false}); + } else { + this.dataSourcesFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: AdditionalMapDataSourceSettings[] | undefined): void { + const dataSources: AdditionalMapDataSourceSettings[] = value || []; + this.dataSourcesFormGroup.setControl('dataSources', this.prepareDataSourcesFormArray(dataSources), {emitEvent: false}); + } + + public validate(c: UntypedFormControl) { + const valid = this.dataSourcesFormGroup.valid; + return valid ? null : { + dataSources: { + valid: false, + }, + }; + } + + dataSourcesFormArray(): UntypedFormArray { + return this.dataSourcesFormGroup.get('dataSources') as UntypedFormArray; + } + + trackByDataSource(index: number, dataSourceControl: AbstractControl): any { + return dataSourceControl; + } + + removeDataSource(index: number) { + (this.dataSourcesFormGroup.get('dataSources') as UntypedFormArray).removeAt(index); + } + + addDataSource() { + const dataSource = mergeDeep({} as AdditionalMapDataSourceSettings, + defaultAdditionalMapDataSourceSettings(this.context.functionsOnly)); + const dataSourcesArray = this.dataSourcesFormGroup.get('dataSources') as UntypedFormArray; + const dataSourceControl = this.fb.control(dataSource, [additionalMapDataSourceValidator]); + dataSourcesArray.push(dataSourceControl); + } + + private prepareDataSourcesFormArray(dataSources: AdditionalMapDataSourceSettings[]): UntypedFormArray { + const dataSourcesControls: Array = []; + dataSources.forEach((dataSource) => { + dataSourcesControls.push(this.fb.control(dataSource, [additionalMapDataSourceValidator])); + }); + return this.fb.array(dataSourcesControls); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.html new file mode 100644 index 0000000000..721f93f5a9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.html @@ -0,0 +1,84 @@ + +
    + + + + + + + {{ mapProviderTranslationMap.get(provider) | translate }} + + + + + + + {{ openStreetMapLayerTranslationMap.get(layerType) | translate }} + + + + + + + {{ googleMapLayerTranslationMap.get(layerType) | translate }} + + + + + + + {{ hereLayerTranslationMap.get(layerType) | translate }} + + + + + + + {{ tencentLayerTranslationMap.get(layerType) | translate }} + + + + + + +
    + +
    + +
    +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.scss new file mode 100644 index 0000000000..89949ec96e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.scss @@ -0,0 +1,39 @@ +/** + * 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-form-table-row.tb-map-layer-row { + + .tb-label-field { + flex: 1 1 33.33%; + } + + .tb-provider-field { + flex: 1 1 33.33%; + } + + .tb-layer-field { + flex: 1 1 33.33%; + @media #{$mat-xs} { + display: none; + } + } + + .tb-remove-button { + width: 40px; + min-width: 40px; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.ts new file mode 100644 index 0000000000..392c9f9565 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.ts @@ -0,0 +1,231 @@ +/// +/// 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, + DestroyRef, + EventEmitter, + forwardRef, + Input, + OnInit, + Output, + Renderer2, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { TranslateService } from '@ngx-translate/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + defaultLayerTitle, + defaultMapLayerSettings, + googleMapLayerTranslationMap, + googleMapLayerTypes, + hereLayerTranslationMap, + hereLayerTypes, + MapLayerSettings, + MapProvider, + mapProviders, + mapProviderTranslationMap, + openStreetLayerTypes, + openStreetMapLayerTranslationMap, + tencentLayerTranslationMap, + tencentLayerTypes +} from '@shared/models/widget/maps/map.models'; +import { deepClone } from '@core/utils'; +import { + MapLayerSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/map/map-layer-settings-panel.component'; + +@Component({ + selector: 'tb-map-layer-row', + templateUrl: './map-layer-row.component.html', + styleUrls: ['./map-layer-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapLayerRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class MapLayerRowComponent implements ControlValueAccessor, OnInit { + + MapProvider = MapProvider; + + mapProviders = mapProviders; + + mapProviderTranslationMap = mapProviderTranslationMap; + + openStreetLayerTypes = openStreetLayerTypes; + + openStreetMapLayerTranslationMap = openStreetMapLayerTranslationMap; + + googleMapLayerTypes = googleMapLayerTypes; + + googleMapLayerTranslationMap = googleMapLayerTranslationMap; + + hereLayerTypes = hereLayerTypes; + + hereLayerTranslationMap = hereLayerTranslationMap; + + tencentLayerTypes = tencentLayerTypes; + + tencentLayerTranslationMap = tencentLayerTranslationMap; + + @Input() + disabled: boolean; + + @Output() + layerRemoved = new EventEmitter(); + + layerFormGroup: UntypedFormGroup; + + modelValue: MapLayerSettings; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private translate: TranslateService, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + this.layerFormGroup = this.fb.group({ + label: [null, []], + provider: [null, [Validators.required]], + layerType: [null, [Validators.required]], + tileUrl: [null, [Validators.required]], + apiKey: [null, [Validators.required]], + referenceLayer: [null, []] + }); + this.layerFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => this.updateModel() + ); + this.layerFormGroup.get('provider').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((newProvider: MapProvider) => { + this.onProviderChanged(newProvider); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.layerFormGroup.disable({emitEvent: false}); + } else { + this.layerFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: MapLayerSettings): void { + this.modelValue = value; + this.layerFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updateValidators(); + this.cd.markForCheck(); + } + + labelPlaceholder(): string { + let translationKey = defaultLayerTitle(this.modelValue); + if (!translationKey) { + translationKey = 'widget-config.set'; + } + return this.translate.instant(translationKey); + } + + editLayer($event: Event, matButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const ctx: any = { + mapLayerSettings: deepClone(this.modelValue) + }; + const mapLayerSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, MapLayerSettingsPanelComponent, ['leftOnly', 'leftTopOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + mapLayerSettingsPanelPopover.tbComponentRef.instance.popover = mapLayerSettingsPanelPopover; + mapLayerSettingsPanelPopover.tbComponentRef.instance.mapLayerSettingsApplied.subscribe((layer) => { + mapLayerSettingsPanelPopover.hide(); + this.layerFormGroup.patchValue( + layer, + {emitEvent: false}); + this.updateValidators(); + this.updateModel(); + }); + } + } + + private onProviderChanged(newProvider: MapProvider) { + this.modelValue = {...defaultMapLayerSettings(newProvider), label: this.modelValue.label}; + this.layerFormGroup.patchValue( + this.modelValue, {emitEvent: false} + ); + this.updateValidators(); + } + + private updateValidators() { + const provider: MapProvider = this.layerFormGroup.get('provider').value; + if (provider === MapProvider.custom) { + this.layerFormGroup.get('tileUrl').enable({emitEvent: false}); + this.layerFormGroup.get('layerType').disable({emitEvent: false}); + } else { + this.layerFormGroup.get('tileUrl').disable({emitEvent: false}); + this.layerFormGroup.get('layerType').enable({emitEvent: false}); + } + if ([MapProvider.google, MapProvider.here].includes(provider)) { + this.layerFormGroup.get('apiKey').enable({emitEvent: false}); + } else { + this.layerFormGroup.get('apiKey').disable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = this.layerFormGroup.value; + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.html new file mode 100644 index 0000000000..cf4c4b90c4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.html @@ -0,0 +1,112 @@ + +
    +
    widgets.maps.layer.layer-settings
    +
    +
    +
    widgets.maps.layer.label
    + + + +
    +
    +
    widgets.maps.layer.provider.provider
    + + + + {{ mapProviderTranslationMap.get(provider) | translate }} + + + +
    +
    +
    {{ (MapProvider.custom === layerFormGroup.get('provider').value ? 'widgets.maps.layer.provider.custom.tile-url' : 'widgets.maps.layer.layer') | translate }}
    + + + + {{ openStreetMapLayerTranslationMap.get(layerType) | translate }} + + + + + + + {{ googleMapLayerTranslationMap.get(layerType) | translate }} + + + + + + + {{ hereLayerTranslationMap.get(layerType) | translate }} + + + + + + + {{ tencentLayerTranslationMap.get(layerType) | translate }} + + + + + + +
    +
    +
    widgets.maps.layer.credentials.credentials
    +
    +
    widgets.maps.layer.credentials.api-key
    + + + +
    +
    +
    +
    widgets.maps.layer.reference.reference-layer
    + + + {{ 'widgets.maps.layer.reference.no-layer' | translate }} + + {{ referenceLayerTypeTranslationMap.get(layer) | translate }} + + + +
    +
    +
    + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.scss new file mode 100644 index 0000000000..e45acbb4db --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.scss @@ -0,0 +1,49 @@ +/** + * 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-map-layer-settings-panel { + width: 540px; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-lt-md} { + width: 90vw; + } + .tb-map-layer-settings-panel-content { + display: flex; + flex-direction: column; + gap: 16px; + overflow: auto; + margin: -10px; + padding: 10px; + } + .tb-map-layer-settings-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-map-layer-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/map-layer-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.ts new file mode 100644 index 0000000000..1d4adbbb4c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.ts @@ -0,0 +1,153 @@ +/// +/// 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 { + defaultLayerTitle, + defaultMapLayerSettings, + googleMapLayerTranslationMap, + googleMapLayerTypes, + hereLayerTranslationMap, + hereLayerTypes, + MapLayerSettings, + MapProvider, + mapProviders, + mapProviderTranslationMap, + openStreetLayerTypes, + openStreetMapLayerTranslationMap, referenceLayerTypes, referenceLayerTypeTranslationMap, + tencentLayerTranslationMap, + tencentLayerTypes +} from '@shared/models/widget/maps/map.models'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'tb-map-layer-settings-panel', + templateUrl: './map-layer-settings-panel.component.html', + providers: [], + styleUrls: ['./map-layer-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class MapLayerSettingsPanelComponent implements OnInit { + + MapProvider = MapProvider; + + mapProviders = mapProviders; + + mapProviderTranslationMap = mapProviderTranslationMap; + + openStreetLayerTypes = openStreetLayerTypes; + + openStreetMapLayerTranslationMap = openStreetMapLayerTranslationMap; + + googleMapLayerTypes = googleMapLayerTypes; + + googleMapLayerTranslationMap = googleMapLayerTranslationMap; + + hereLayerTypes = hereLayerTypes; + + hereLayerTranslationMap = hereLayerTranslationMap; + + tencentLayerTypes = tencentLayerTypes; + + tencentLayerTranslationMap = tencentLayerTranslationMap; + + referenceLayerTypes = referenceLayerTypes; + + referenceLayerTypeTranslationMap = referenceLayerTypeTranslationMap; + + @Input() + mapLayerSettings: MapLayerSettings; + + @Input() + popover: TbPopoverComponent; + + @Output() + mapLayerSettingsApplied = new EventEmitter(); + + layerFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private translate: TranslateService, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + this.layerFormGroup = this.fb.group( + { + label: [null, []], + provider: [null, [Validators.required]], + layerType: [null, [Validators.required]], + tileUrl: [null, [Validators.required]], + apiKey: [null, [Validators.required]], + referenceLayer: [null, []] + } + ); + this.layerFormGroup.patchValue( + this.mapLayerSettings, {emitEvent: false} + ); + this.layerFormGroup.get('provider').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((newProvider: MapProvider) => { + this.onProviderChanged(newProvider); + }); + this.updateValidators(); + } + + cancel() { + this.popover?.hide(); + } + + labelPlaceholder(): string { + let translationKey = defaultLayerTitle(this.layerFormGroup.value); + if (!translationKey) { + translationKey = 'widget-config.set'; + } + return this.translate.instant(translationKey); + } + + applyLayerSettings() { + const layerSettings: MapLayerSettings = this.layerFormGroup.value; + this.mapLayerSettingsApplied.emit(layerSettings); + } + + private onProviderChanged(newProvider: MapProvider) { + let modelValue: MapLayerSettings = this.layerFormGroup.value; + modelValue = {...defaultMapLayerSettings(newProvider), label: modelValue.label}; + this.layerFormGroup.patchValue( + modelValue, {emitEvent: false} + ); + this.updateValidators(); + } + + private updateValidators() { + const provider: MapProvider = this.layerFormGroup.get('provider').value; + if (provider === MapProvider.custom) { + this.layerFormGroup.get('tileUrl').enable({emitEvent: false}); + this.layerFormGroup.get('layerType').disable({emitEvent: false}); + } else { + this.layerFormGroup.get('tileUrl').disable({emitEvent: false}); + this.layerFormGroup.get('layerType').enable({emitEvent: false}); + } + if ([MapProvider.google, MapProvider.here].includes(provider)) { + this.layerFormGroup.get('apiKey').enable({emitEvent: false}); + } else { + this.layerFormGroup.get('apiKey').disable({emitEvent: false}); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.html new file mode 100644 index 0000000000..1f0c930802 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.html @@ -0,0 +1,57 @@ + +
    +
    +
    +
    widgets.maps.layer.label
    +
    widgets.maps.layer.provider.provider
    +
    widgets.maps.layer.layer
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    + +
    +
    + + {{ 'widgets.maps.layer.no-layers' | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.scss new file mode 100644 index 0000000000..e5b035ddd3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.scss @@ -0,0 +1,43 @@ +/** + * 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-map-layers { + .tb-form-table-header-cell { + &.tb-label-header { + flex: 1 1 33.33%; + } + &.tb-provider-header { + flex: 1 1 33.33%; + } + &.tb-layer-header { + flex: 1 1 33.33%; + @media #{$mat-xs} { + display: none; + } + } + &.tb-actions-header { + width: 120px; + min-width: 120px; + } + } + + .tb-form-table-body { + tb-map-layer-row { + overflow: hidden; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.ts new file mode 100644 index 0000000000..60e9f981fa --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.ts @@ -0,0 +1,156 @@ +/// +/// 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, forwardRef, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator +} from '@angular/forms'; +import { mergeDeep } from '@core/utils'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + defaultMapLayerSettings, + MapLayerSettings, + mapLayerValid, + mapLayerValidator, + MapProvider +} from '@shared/models/widget/maps/map.models'; + +@Component({ + selector: 'tb-map-layers', + templateUrl: './map-layers.component.html', + styleUrls: ['./map-layers.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapLayersComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapLayersComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class MapLayersComponent implements ControlValueAccessor, OnInit, Validator { + + @Input() + disabled: boolean; + + layersFormGroup: UntypedFormGroup; + + get dragEnabled(): boolean { + return this.layersFormArray().controls.length > 1; + } + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + this.layersFormGroup = this.fb.group({ + layers: [this.fb.array([]), []] + }); + this.layersFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => { + let layers: MapLayerSettings[] = this.layersFormGroup.get('layers').value; + if (layers) { + layers = layers.filter(layer => mapLayerValid(layer)); + } + this.propagateChange(layers); + } + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.layersFormGroup.disable({emitEvent: false}); + } else { + this.layersFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: MapLayerSettings[] | undefined): void { + const layers: MapLayerSettings[] = value || []; + this.layersFormGroup.setControl('layers', this.prepareLayersFormArray(layers), {emitEvent: false}); + } + + public validate(c: UntypedFormControl) { + const valid = this.layersFormGroup.valid; + return valid ? null : { + layers: { + valid: false, + }, + }; + } + + layerDrop(event: CdkDragDrop) { + const layersArray = this.layersFormGroup.get('layers') as UntypedFormArray; + const layer = layersArray.at(event.previousIndex); + layersArray.removeAt(event.previousIndex); + layersArray.insert(event.currentIndex, layer); + } + + layersFormArray(): UntypedFormArray { + return this.layersFormGroup.get('layers') as UntypedFormArray; + } + + trackByLayer(index: number, layerControl: AbstractControl): any { + return layerControl; + } + + removeLayer(index: number) { + (this.layersFormGroup.get('layers') as UntypedFormArray).removeAt(index); + } + + addLayer() { + const layer = mergeDeep({} as MapLayerSettings, + defaultMapLayerSettings(MapProvider.openstreet)); + const layersArray = this.layersFormGroup.get('layers') as UntypedFormArray; + const layerControl = this.fb.control(layer, [mapLayerValidator]); + layersArray.push(layerControl); + } + + private prepareLayersFormArray(layers: MapLayerSettings[]): UntypedFormArray { + const layersControls: Array = []; + layers.forEach((layer) => { + layersControls.push(this.fb.control(layer, [mapLayerValidator])); + }); + return this.fb.array(layersControls); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html new file mode 100644 index 0000000000..38a5d13922 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html @@ -0,0 +1,152 @@ + + +
    +
    +
    + {{ 'widgets.maps.map-type.type' | translate }} +
    + + {{ 'widgets.maps.map-type.map' | translate }} + {{ 'widgets.maps.map-type.image' | translate }} + +
    + + + + +
    +
    +
    +
    + {{ 'widgets.maps.overlays.overlays' | translate }} +
    + + {{ 'widgets.maps.overlays.trips' | translate }} + {{ 'widgets.maps.overlays.markers' | translate }} + {{ 'widgets.maps.overlays.polygons' | translate }} + {{ 'widgets.maps.overlays.circles' | translate }} + +
    + + + + +
    +
    +
    + {{ 'widgets.maps.data-layer.additional-datasources' | translate }} +
    + + +
    +
    +
    + {{ 'widgets.maps.control.map-controls' | translate }} +
    +
    +
    widgets.maps.control.position
    + + + + {{ mapControlsPositionTranslationMap.get(position) | translate }} + + + +
    +
    +
    widgets.maps.control.zoom-actions
    + + + {{ mapZoomActionTranslationMap.get(action) | translate }} + + +
    +
    +
    widgets.maps.control.scale
    + + + {{ mapScaleTranslationMap.get(scale) | translate }} + + +
    +
    + + {{ 'widgets.maps.control.switch-to-drag-mode-using-button' | translate }} + +
    + +
    + + +
    +
    + {{ 'widgets.maps.common.common-map-settings' | translate }} +
    + +
    + + {{ 'widgets.maps.common.fit-map-bounds' | translate }} + +
    +
    + + {{ 'widgets.maps.common.default-map-center-position' | translate }} + + + + +
    +
    +
    widgets.maps.common.default-map-zoom-level
    + + + +
    +
    +
    +
    widgets.maps.common.entities-limit
    + + + +
    +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.models.ts new file mode 100644 index 0000000000..9ce92c5c4a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.models.ts @@ -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. +/// + +import { IAliasController } from '@core/api/widget-api.models'; +import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; +import { DataKey, Widget, widgetType } from '@shared/models/widget.models'; +import { Observable } from 'rxjs'; + +export interface MapSettingsContext { + functionsOnly: boolean; + aliasController: IAliasController; + callbacks: WidgetConfigCallbacks; + widget: Widget; + editKey: (key: DataKey, deviceId: string, entityAliasId: string, WidgetType?: widgetType) => Observable; + generateDataKey: (key: DataKey) => DataKey; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts new file mode 100644 index 0000000000..f38f2a042c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts @@ -0,0 +1,312 @@ +/// +/// 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, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + Validators +} from '@angular/forms'; +import { + DataLayerEditAction, + defaultImageMapSourceSettings, + ImageMapSourceSettings, + imageMapSourceSettingsValidator, + mapControlPositions, + mapControlsPositionTranslationMap, + MapDataLayerSettings, + MapDataLayerType, mapScales, mapScaleTranslationMap, + MapSetting, + MapType, + mapZoomActions, + mapZoomActionTranslationMap +} from '@shared/models/widget/maps/map.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { merge, Observable } from 'rxjs'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { IAliasController } from '@core/api/widget-api.models'; +import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; +import { DataKey, DataKeyConfigMode, Widget, widgetType } from '@shared/models/widget.models'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; +import { + DataKeyConfigDialogComponent, + DataKeyConfigDialogData +} from '@home/components/widget/lib/settings/common/key/data-key-config-dialog.component'; +import { deepClone, mergeDeep } from '@core/utils'; +import { MatDialog } from '@angular/material/dialog'; + +@Component({ + selector: 'tb-map-settings', + templateUrl: './map-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapSettingsComponent), + multi: true + } + ] +}) +export class MapSettingsComponent implements OnInit, ControlValueAccessor, Validator { + + mapControlPositions = mapControlPositions; + + mapControlsPositionTranslationMap = mapControlsPositionTranslationMap; + + mapZoomActions = mapZoomActions; + + mapZoomActionTranslationMap = mapZoomActionTranslationMap; + + mapScales = mapScales; + + mapScaleTranslationMap = mapScaleTranslationMap; + + MapType = MapType; + + @Input() + @coerceBoolean() + trip = false; + + @Input() + disabled: boolean; + + @Input() + @coerceBoolean() + functionsOnly = false; + + @Input() + aliasController: IAliasController; + + @Input() + callbacks: WidgetConfigCallbacks; + + @Input() + widget: Widget; + + context: MapSettingsContext; + + private modelValue: MapSetting; + + private propagateChange = null; + + public mapSettingsFormGroup: UntypedFormGroup; + + dataLayerMode: MapDataLayerType = 'markers'; + + showDragButtonModeButtonSettings = false; + + constructor(private fb: UntypedFormBuilder, + private dialog: MatDialog, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + + if (this.trip) { + this.dataLayerMode = 'trips'; + } + + this.context = { + functionsOnly: this.functionsOnly, + aliasController: this.aliasController, + callbacks: this.callbacks, + widget: this.widget, + editKey: this.editKey.bind(this), + generateDataKey: this.generateDataKey.bind(this) + }; + + this.mapSettingsFormGroup = this.fb.group({ + mapType: [null, []], + layers: [null, []], + imageSource: [null, [imageMapSourceSettingsValidator]], + markers: [null, []], + polygons: [null, []], + circles: [null, []], + additionalDataSources: [null, []], + controlsPosition: [null, []], + zoomActions: [null, []], + scales: [null, []], + dragModeButton: [null, []], + fitMapBounds: [null, []], + useDefaultCenterPosition: [null, []], + defaultCenterPosition: [null, []], + defaultZoomLevel: [null, [Validators.min(0), Validators.max(20)]], + mapPageSize: [null, [Validators.min(1), Validators.required]], + mapActionButtons: [null] + }); + if (this.trip) { + this.mapSettingsFormGroup.addControl('trips', this.fb.control(null)); + this.mapSettingsFormGroup.addControl('tripTimeline', this.fb.control(null)); + } + this.mapSettingsFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + merge(this.mapSettingsFormGroup.get('mapType').valueChanges, + this.mapSettingsFormGroup.get('useDefaultCenterPosition').valueChanges + ).pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateValidators(); + }); + this.mapSettingsFormGroup.get('mapType').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((mapType: MapType) => { + this.mapTypeChanged(mapType); + }); + merge(this.mapSettingsFormGroup.get('markers').valueChanges, + this.mapSettingsFormGroup.get('polygons').valueChanges, + this.mapSettingsFormGroup.get('circles').valueChanges + ).pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateDragButtonModeSettings(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.mapSettingsFormGroup.disable({emitEvent: false}); + } else { + this.mapSettingsFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: MapSetting): void { + this.modelValue = value; + this.mapSettingsFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updateValidators(); + this.updateDragButtonModeSettings(); + } + + public validate(_c: UntypedFormControl) { + const valid = this.mapSettingsFormGroup.valid; + return valid ? null : { + mapSettings: { + valid: false, + }, + }; + } + + private updateValidators() { + const mapType: MapType = this.mapSettingsFormGroup.get('mapType').value; + if (mapType === MapType.geoMap) { + this.mapSettingsFormGroup.get('layers').enable({emitEvent: false}); + this.mapSettingsFormGroup.get('imageSource').disable({emitEvent: false}); + this.mapSettingsFormGroup.get('fitMapBounds').enable({emitEvent: false}); + this.mapSettingsFormGroup.get('useDefaultCenterPosition').enable({emitEvent: false}); + const useDefaultCenterPosition: boolean = this.mapSettingsFormGroup.get('useDefaultCenterPosition').value; + if (useDefaultCenterPosition) { + this.mapSettingsFormGroup.get('defaultCenterPosition').enable({emitEvent: false}); + } else { + this.mapSettingsFormGroup.get('defaultCenterPosition').disable({emitEvent: false}); + } + this.mapSettingsFormGroup.get('defaultZoomLevel').enable({emitEvent: false}); + } else { + this.mapSettingsFormGroup.get('layers').disable({emitEvent: false}); + this.mapSettingsFormGroup.get('imageSource').enable({emitEvent: false}); + this.mapSettingsFormGroup.get('fitMapBounds').disable({emitEvent: false}); + this.mapSettingsFormGroup.get('useDefaultCenterPosition').disable({emitEvent: false}); + this.mapSettingsFormGroup.get('defaultCenterPosition').disable({emitEvent: false}); + this.mapSettingsFormGroup.get('defaultZoomLevel').disable({emitEvent: false}); + } + } + + private mapTypeChanged(mapType: MapType): void { + if (mapType === MapType.image) { + let imageSource: ImageMapSourceSettings = this.mapSettingsFormGroup.get('imageSource').value; + if (!imageSource?.sourceType) { + imageSource = mergeDeep({} as ImageMapSourceSettings, defaultImageMapSourceSettings); + this.mapSettingsFormGroup.get('imageSource').patchValue(imageSource); + } + } + } + + private updateDragButtonModeSettings() { + const markers: MapDataLayerSettings[] = this.mapSettingsFormGroup.get('markers').value; + let dragModeButtonSettingsEnabled = markers.some(d => d.edit && d.edit.enabledActions && d.edit.enabledActions.includes(DataLayerEditAction.move)); + if (!dragModeButtonSettingsEnabled) { + const polygons: MapDataLayerSettings[] = this.mapSettingsFormGroup.get('polygons').value; + dragModeButtonSettingsEnabled = polygons.some(d => d.edit && d.edit.enabledActions && d.edit.enabledActions.includes(DataLayerEditAction.move)); + } + if (!dragModeButtonSettingsEnabled) { + const circles: MapDataLayerSettings[] = this.mapSettingsFormGroup.get('circles').value; + dragModeButtonSettingsEnabled = circles.some(d => d.edit && d.edit.enabledActions && d.edit.enabledActions.includes(DataLayerEditAction.move)); + } + this.showDragButtonModeButtonSettings = dragModeButtonSettingsEnabled; + if (dragModeButtonSettingsEnabled) { + this.mapSettingsFormGroup.get('dragModeButton').enable({emitEvent: false}); + } else { + this.mapSettingsFormGroup.get('dragModeButton').disable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = this.mapSettingsFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + } + + private editKey(key: DataKey, deviceId: string, entityAliasId: string, _widgetType = widgetType.latest): Observable { + return this.dialog.open(DataKeyConfigDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + dataKey: deepClone(key), + dataKeyConfigMode: DataKeyConfigMode.general, + aliasController: this.aliasController, + widgetType: _widgetType, + deviceId, + entityAliasId, + showPostProcessing: true, + callbacks: this.callbacks, + hideDataKeyColor: true, + hideDataKeyDecimals: true, + hideDataKeyUnits: true, + widget: this.widget, + dashboard: null, + dataKeySettingsForm: null, + dataKeySettingsDirective: null + } + }).afterClosed(); + } + + private generateDataKey(key: DataKey): DataKey { + return this.callbacks.generateDataKey(key.name, key.type, null, false, null); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.html new file mode 100644 index 0000000000..d743f70919 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.html @@ -0,0 +1,67 @@ + +
    +
    widgets.maps.data-layer.tooltip-tag-actions
    +
    + + +
    +
    +
    + + {{ action.name }} + +
    +
    + + +
    +
    +
    + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.scss new file mode 100644 index 0000000000..9e75d6d66c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.scss @@ -0,0 +1,49 @@ +/** + * 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. + */ +.mat-mdc-chip.mat-mdc-standard-chip.tb-tag-action-chip { + overflow: hidden; + line-height: 20px; + height: 32px; + + &.mdc-evolution-chip--with-trailing-action { + .mdc-evolution-chip__action--primary { + padding-left: 4px; + padding-right: 12px; + } + } + + .tb-chip-labels { + display: flex; + flex-direction: row; + align-items: center; + min-width: 0; + padding: 2px 10px; + .tb-chip-label { + font-weight: normal; + font-size: 14px; + line-height: 20px; + &.tb-chip-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + } + .mat-mdc-chip-remove.mat-mdc-icon-button { + color: inherit; + opacity: inherit; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.ts new file mode 100644 index 0000000000..ba52e41b84 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.ts @@ -0,0 +1,176 @@ +/// +/// 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, + forwardRef, + Input, + OnInit, + Renderer2, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { WidgetAction, WidgetActionType, widgetType } from '@shared/models/widget.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; +import { + WidgetActionSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/action/widget-action-settings-panel.component'; +import { MatButton } from '@angular/material/button'; +import { TranslateService } from '@ngx-translate/core'; +import { deepClone } from '@core/utils'; + +@Component({ + selector: 'tb-map-tooltip-tag-actions-panel', + templateUrl: './map-tooltip-tag-actions.component.html', + styleUrls: ['./map-tooltip-tag-actions.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapTooltipTagActionsComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class MapTooltipTagActionsComponent implements ControlValueAccessor, OnInit { + + @Input() + disabled: boolean; + + @Input() + context: MapSettingsContext; + + actionsFormGroup: UntypedFormGroup; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private translate: TranslateService, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + this.actionsFormGroup = this.fb.group({ + actions: [null, []] + }); + this.actionsFormGroup.get('actions').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + (val) => this.propagateChange(val) + ); + } + + writeValue(actions?: WidgetAction[]): void { + this.actionsFormGroup.get('actions').patchValue(actions || [], {emitEvent: false}); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.actionsFormGroup.disable({emitEvent: false}); + } else { + this.actionsFormGroup.enable({emitEvent: false}); + } + } + + removeAction(index: number): void { + const actions: WidgetAction[] = this.actionsFormGroup.get('actions').value; + if (actions[index]) { + actions.splice(index, 1); + this.actionsFormGroup.get('actions').patchValue(actions); + } + } + + addAction($event: Event, matButton: MatButton): void { + if ($event) { + $event.stopPropagation(); + } + const action: WidgetAction = { + name: '', + type: WidgetActionType.doNothing + }; + const trigger = matButton._elementRef.nativeElement; + const actionNames = (this.actionsFormGroup.get('actions').value as WidgetAction[] || []).map(action => action.name); + this.openActionSettingsPopup(trigger, action, actionNames, true, (added) => { + if (added) { + const actions: WidgetAction[] = this.actionsFormGroup.get('actions').value || []; + actions.push(added); + this.actionsFormGroup.get('actions').patchValue(actions); + } + }); + } + + editAction($event: Event, matButton: MatButton, index: number): void { + if ($event) { + $event.stopPropagation(); + } + const actions: WidgetAction[] = this.actionsFormGroup.get('actions').value; + if (actions[index]) { + const action = deepClone(actions[index]); + const trigger = matButton._elementRef.nativeElement; + const actionNames = actions.filter((_action, current) => current !== index).map(action => action.name); + this.openActionSettingsPopup(trigger, action, actionNames, false, (updated) => { + if (updated) { + actions[index] = updated; + this.actionsFormGroup.get('actions').patchValue(actions); + } + }); + } + } + + private openActionSettingsPopup(trigger: Element, action: WidgetAction, actionNames: string[], isAdd: boolean, callback: (action?: WidgetAction) => void) { + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const title = this.translate.instant(isAdd ? 'widgets.maps.data-layer.add-tooltip-tag-action' : 'widgets.maps.data-layer.edit-tooltip-tag-action'); + const applyTitle = this.translate.instant(isAdd ? 'action.add' : 'action.apply'); + const ctx: any = { + widgetAction: action, + withName: true, + actionNames, + panelTitle: title, + applyTitle, + widgetType: widgetType.latest, + callbacks: this.context.callbacks + }; + const widgetActionSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, WidgetActionSettingsPanelComponent, + ['leftTopOnly', 'leftOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + widgetActionSettingsPanelPopover.tbComponentRef.instance.widgetActionApplied.subscribe((widgetAction) => { + widgetActionSettingsPanelPopover.hide(); + callback(widgetAction); + }); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.html new file mode 100644 index 0000000000..2a85f8d514 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.html @@ -0,0 +1,98 @@ + +
    + + + + + {{ 'widgets.maps.data-layer.marker.clustering.use-map-markers-clustering' | translate }} + + + + +
    + + {{ 'widgets.maps.data-layer.marker.clustering.zoom-on-cluster-click' | translate }} + +
    +
    +
    widgets.maps.data-layer.marker.clustering.max-zoom
    + + + +
    +
    +
    widgets.maps.data-layer.marker.clustering.max-radius
    + + + px + +
    +
    + + {{ 'widgets.maps.data-layer.marker.clustering.zoom-animation' | translate }} + +
    +
    + + {{ 'widgets.maps.data-layer.marker.clustering.bounds-on-cluster-mouse-over' | translate }} + +
    +
    + + {{ 'widgets.maps.data-layer.marker.clustering.spiderfy-max-zoom-level' | translate }} + +
    +
    +
    widgets.maps.data-layer.marker.clustering.load-optimization
    +
    + + {{ 'widgets.maps.data-layer.marker.clustering.chunked-load' | translate }} + +
    +
    + + {{ 'widgets.maps.data-layer.marker.clustering.lazy-load' | translate }} + +
    +
    +
    + + + + + {{ 'widgets.maps.data-layer.marker.clustering.use-cluster-marker-color-function' | translate }} + + + + + + + + +
    +
    +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.ts new file mode 100644 index 0000000000..8240b5d6dd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.ts @@ -0,0 +1,156 @@ +/// +/// 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, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + Validators +} from '@angular/forms'; +import { WidgetService } from '@core/http/widget.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MarkerClusteringSettings } from '@shared/models/widget/maps/map.models'; +import { merge } from 'rxjs'; + +@Component({ + selector: 'tb-marker-clustering-settings', + templateUrl: './marker-clustering-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MarkerClusteringSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MarkerClusteringSettingsComponent), + multi: true + } + ] +}) +export class MarkerClusteringSettingsComponent implements OnInit, ControlValueAccessor, Validator { + + settingsExpanded = false; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + @Input() + disabled: boolean; + + private modelValue: MarkerClusteringSettings; + + private propagateChange = null; + + public clusteringSettingsFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + + this.clusteringSettingsFormGroup = this.fb.group({ + enable: [null, []], + zoomOnClick: [null, []], + maxZoom: [null, [Validators.min(0), Validators.max(18)]], + maxClusterRadius: [null, [Validators.min(0)]], + zoomAnimation: [null, []], + showCoverageOnHover: [null, []], + spiderfyOnMaxZoom: [null, []], + chunkedLoad: [null, []], + lazyLoad: [null, []], + useClusterMarkerColorFunction: [null, []], + clusterMarkerColorFunction: [null, []] + }); + this.clusteringSettingsFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + merge(this.clusteringSettingsFormGroup.get('enable').valueChanges, + this.clusteringSettingsFormGroup.get('useClusterMarkerColorFunction').valueChanges).pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateValidators(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.clusteringSettingsFormGroup.disable({emitEvent: false}); + } else { + this.clusteringSettingsFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: MarkerClusteringSettings): void { + this.modelValue = value; + this.clusteringSettingsFormGroup.patchValue( + value, {emitEvent: false} + ); + this.settingsExpanded = this.clusteringSettingsFormGroup.get('enable').value; + this.updateValidators(); + this.clusteringSettingsFormGroup.get('enable').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((enable) => { + this.settingsExpanded = enable; + }); + } + + public validate(c: UntypedFormControl) { + const valid = this.clusteringSettingsFormGroup.valid; + return valid ? null : { + markerClustering: { + valid: false, + }, + }; + } + + private updateValidators() { + const enable: boolean = this.clusteringSettingsFormGroup.get('enable').value; + const useClusterMarkerColorFunction: boolean = this.clusteringSettingsFormGroup.get('useClusterMarkerColorFunction').value; + if (enable) { + this.clusteringSettingsFormGroup.enable({emitEvent: false}); + if (!useClusterMarkerColorFunction) { + this.clusteringSettingsFormGroup.get('clusterMarkerColorFunction').disable({emitEvent: false}); + } + } else { + this.clusteringSettingsFormGroup.disable({emitEvent: false}); + this.clusteringSettingsFormGroup.get('enable').enable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = this.clusteringSettingsFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.html new file mode 100644 index 0000000000..a7e62694df --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.html @@ -0,0 +1,60 @@ + +
    +
    widgets.maps.data-layer.marker.marker-icon
    + +
    widgets.maps.data-layer.marker.marker-appearance
    +
    + + @if (containerInfo.iconContainer === iconContainer) { + + } @else { + + } + +
    +
    + + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.scss new file mode 100644 index 0000000000..9022db881d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.scss @@ -0,0 +1,47 @@ +/** + * 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. + */ +.tb-marker-icon-shapes-panel { + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; + .tb-marker-icon-shapes-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + button.mat-mdc-button-base.tb-select-shape-button { + width: 42px; + min-width: 42px; + height: 42px; + padding: 4px; + div.tb-marker-shape { + width: 34px; + height: 34px; + object-fit: contain; + } + } + .tb-marker-icon-shapes-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/marker-icon-shapes.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.ts new file mode 100644 index 0000000000..3240ce11dd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.ts @@ -0,0 +1,127 @@ +/// +/// 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, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + createColorMarkerIconElement, + MarkerIconContainer, markerIconContainers, + tripMarkerIconContainers +} from '@shared/models/widget/maps/marker-shape.models'; +import { Observable } from 'rxjs'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { MatIconRegistry } from '@angular/material/icon'; +import tinycolor from 'tinycolor2'; +import { map, share } from 'rxjs/operators'; +import { coerceBoolean } from '@shared/decorators/coercion'; + +export interface MarkerIconInfo { + iconContainer?: MarkerIconContainer; + icon: string; +} + +interface MarkerIconContainerInfo { + iconContainer: MarkerIconContainer; + html$: Observable; +} + +@Component({ + selector: 'tb-marker-icon-shapes', + templateUrl: './marker-icon-shapes.component.html', + providers: [], + styleUrls: ['./marker-icon-shapes.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class MarkerIconShapesComponent extends PageComponent implements OnInit { + + @Input() + icon: string; + + @Input() + iconContainer: MarkerIconContainer; + + @Input() + color: string; + + @Input() + @coerceBoolean() + trip = false; + + @Input() + popover: TbPopoverComponent; + + @Output() + markerIconSelected = new EventEmitter(); + + dirty = false; + + iconContainers: MarkerIconContainerInfo[]; + + constructor(protected store: Store, + private iconRegistry: MatIconRegistry, + private domSanitizer: DomSanitizer) { + super(store); + } + + ngOnInit(): void { + this.updateIconContainers(); + } + + cancel() { + this.popover?.hide(); + } + + selectIcon(icon: string) { + if (this.icon !== icon) { + this.icon = icon; + this.dirty = true; + this.updateIconContainers(); + } + } + + selectIconContainer(iconContainer: MarkerIconContainer) { + if (this.iconContainer !== iconContainer) { + this.iconContainer = iconContainer; + this.dirty = true; + } + } + + apply() { + const iconInfo: MarkerIconInfo = { + iconContainer: this.iconContainer, + icon: this.icon + }; + this.markerIconSelected.emit(iconInfo); + } + + private updateIconContainers() { + const containersList = [...(this.trip ? tripMarkerIconContainers : markerIconContainers),null]; + this.iconContainers = containersList.map((iconContainer) => { + return { + iconContainer, + html$: createColorMarkerIconElement(this.iconRegistry, this.domSanitizer, iconContainer, this.icon, tinycolor(this.color)).pipe( + map((element) => { + return this.domSanitizer.bypassSecurityTrustHtml(element.outerHTML); + }), + share() + ) + }; + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.html new file mode 100644 index 0000000000..ff06899a8b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.html @@ -0,0 +1,72 @@ + +
    +
    widgets.maps.data-layer.marker.marker-image
    +
    + + + {{ 'widgets.maps.data-layer.marker.marker-image-type-image' | translate }} + + + {{ 'widgets.maps.data-layer.marker.marker-image-type-function' | translate }} + + +
    +
    +
    + +
    +
    {{ 'widgets.maps.data-layer.marker.custom-marker-image-size' | translate }}
    + + +
    px
    +
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    + + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.scss new file mode 100644 index 0000000000..9a53a50729 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.scss @@ -0,0 +1,53 @@ +/** + * 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-marker-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-marker-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-marker-image-settings-panel-body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + } + .tb-marker-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/marker-image-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.ts new file mode 100644 index 0000000000..72edca5e81 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-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 { PageComponent } from '@shared/components/page.component'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { WidgetService } from '@core/http/widget.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MarkerImageSettings, MarkerImageType } from '@shared/models/widget/maps/map.models'; + +@Component({ + selector: 'tb-marker-image-settings-panel', + templateUrl: './marker-image-settings-panel.component.html', + providers: [], + styleUrls: ['./marker-image-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class MarkerImageSettingsPanelComponent extends PageComponent implements OnInit { + + @Input() + markerImageSettings: MarkerImageSettings; + + @Input() + popover: TbPopoverComponent; + + @Output() + markerImageSettingsApplied = new EventEmitter(); + + MarkerImageType = MarkerImageType; + + markerImageSettingsFormGroup: UntypedFormGroup; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService, + protected store: Store, + private destroyRef: DestroyRef) { + super(store); + } + + ngOnInit(): void { + this.markerImageSettingsFormGroup = this.fb.group( + { + type: [this.markerImageSettings?.type || MarkerImageType.image, []], + image: [this.markerImageSettings?.image, [Validators.required]], + imageSize: [this.markerImageSettings?.imageSize, [Validators.min(1)]], + imageFunction: [this.markerImageSettings?.imageFunction, [Validators.required]], + images: [this.markerImageSettings?.images, []] + } + ); + this.markerImageSettingsFormGroup.get('type').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateValidators(); + setTimeout(() => {this.popover?.updatePosition();}, 0); + }); + this.updateValidators(); + } + + cancel() { + this.popover?.hide(); + } + + applyMarkerImageSettings() { + const markerImageSettings: MarkerImageSettings = this.markerImageSettingsFormGroup.value; + this.markerImageSettingsApplied.emit(markerImageSettings); + } + + private updateValidators() { + const type: MarkerImageType = this.markerImageSettingsFormGroup.get('type').value; + if (type === MarkerImageType.image) { + this.markerImageSettingsFormGroup.get('image').enable({emitEvent: false}); + this.markerImageSettingsFormGroup.get('imageSize').enable({emitEvent: false}); + this.markerImageSettingsFormGroup.get('imageFunction').disable({emitEvent: false}); + this.markerImageSettingsFormGroup.get('images').disable({emitEvent: false}); + } else { + this.markerImageSettingsFormGroup.get('image').disable({emitEvent: false}); + this.markerImageSettingsFormGroup.get('imageSize').disable({emitEvent: false}); + this.markerImageSettingsFormGroup.get('imageFunction').enable({emitEvent: false}); + this.markerImageSettingsFormGroup.get('images').enable({emitEvent: false}); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.html new file mode 100644 index 0000000000..94f959d91f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.html @@ -0,0 +1,28 @@ + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.ts new file mode 100644 index 0000000000..21600392df --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-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 { MarkerImageSettings, MarkerImageType } from '@shared/models/widget/maps/map.models'; +import { + MarkerImageSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/map/marker-image-settings-panel.component'; + +@Component({ + selector: 'tb-marker-image-settings', + templateUrl: './marker-image-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MarkerImageSettingsComponent), + multi: true + } + ] +}) +export class MarkerImageSettingsComponent implements ControlValueAccessor { + + @Input() + disabled: boolean; + + MarkerImageType = MarkerImageType; + + modelValue: MarkerImageSettings; + + 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: MarkerImageSettings): 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 { + const ctx: any = { + markerImageSettings: this.modelValue, + }; + const markerImageSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, MarkerImageSettingsPanelComponent, 'left', true, null, + ctx, + {}, + {}, {}, true); + markerImageSettingsPanelPopover.tbComponentRef.instance.popover = markerImageSettingsPanelPopover; + markerImageSettingsPanelPopover.tbComponentRef.instance.markerImageSettingsApplied.subscribe((markerImageSettings) => { + markerImageSettingsPanelPopover.hide(); + this.modelValue = markerImageSettings; + this.propagateChange(this.modelValue); + this.cd.detectChanges(); + }); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.html new file mode 100644 index 0000000000..0ee3b1de37 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.html @@ -0,0 +1,38 @@ + +
    + + +
    px
    +
    + + +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts new file mode 100644 index 0000000000..4400c5a151 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts @@ -0,0 +1,233 @@ +/// +/// 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, + DestroyRef, + forwardRef, + Input, + OnInit, + Renderer2, + ViewContainerRef +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { MarkerIconSettings, MarkerShapeSettings, MarkerType } from '@shared/models/widget/maps/map.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Observable } from 'rxjs'; +import { DomSanitizer, SafeHtml, SafeUrl } from '@angular/platform-browser'; +import { MatIconRegistry } from '@angular/material/icon'; +import { + createColorMarkerIconElement, + createColorMarkerShapeURI +} from '@shared/models/widget/maps/marker-shape.models'; +import tinycolor from 'tinycolor2'; +import { map, share } from 'rxjs/operators'; +import { MarkerShapesComponent } from '@home/components/widget/lib/settings/common/map/marker-shapes.component'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { + MarkerIconShapesComponent +} from '@home/components/widget/lib/settings/common/map/marker-icon-shapes.component'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; +import { DatasourceType } from '@shared/models/widget.models'; + +@Component({ + selector: 'tb-marker-shape-settings', + templateUrl: './marker-shape-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MarkerShapeSettingsComponent), + multi: true + } + ] +}) +export class MarkerShapeSettingsComponent implements ControlValueAccessor, OnInit { + + MarkerType = MarkerType; + + @Input() + disabled: boolean; + + @Input() + context: MapSettingsContext; + + @Input() + dsType: DatasourceType; + + @Input() + dsEntityAliasId: string; + + @Input() + dsDeviceId: string; + + @Input() + markerType: MarkerType; + + @Input() + @coerceBoolean() + trip = false; + + modelValue: MarkerShapeSettings | MarkerIconSettings; + + public shapeSettingsFormGroup: UntypedFormGroup; + + public shapePreview$: Observable; + public iconPreview$: Observable; + + private propagateChange: (v: any) => void = () => { }; + + constructor(private popoverService: TbPopoverService, + private fb: UntypedFormBuilder, + private destroyRef: DestroyRef, + private iconRegistry: MatIconRegistry, + private domSanitizer: DomSanitizer, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef) {} + + ngOnInit(): void { + this.shapeSettingsFormGroup = this.fb.group({ + size: [null, [Validators.required, Validators.min(1)]], + color: [null, [Validators.required]] + }); + if (this.markerType === MarkerType.shape) { + this.shapeSettingsFormGroup.addControl('shape', this.fb.control(null, [Validators.required])); + } + if (this.markerType === MarkerType.icon) { + this.shapeSettingsFormGroup.addControl('iconContainer', this.fb.control(null, [])); + this.shapeSettingsFormGroup.addControl('icon', this.fb.control(null, [Validators.required])); + } + this.shapeSettingsFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.shapeSettingsFormGroup.disable({emitEvent: false}); + } else { + this.shapeSettingsFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: MarkerShapeSettings | MarkerIconSettings): void { + this.modelValue = value; + this.shapeSettingsFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updatePreview(); + } + + openShapePopup($event: Event, matButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + if (this.markerType === MarkerType.shape) { + const ctx: any = { + shape: (this.modelValue as MarkerShapeSettings).shape, + color: this.modelValue.color.color, + trip: this.trip + }; + const markerShapesPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, MarkerShapesComponent, 'left', true, null, + ctx, + {}, + {}, {}, true); + markerShapesPopover.tbComponentRef.instance.popover = markerShapesPopover; + markerShapesPopover.tbComponentRef.instance.markerShapeSelected.subscribe((shape) => { + markerShapesPopover.hide(); + this.shapeSettingsFormGroup.get('shape').patchValue( + shape + ); + }); + } else if (this.markerType === MarkerType.icon) { + const ctx: any = { + iconContainer: (this.modelValue as MarkerIconSettings).iconContainer, + icon: (this.modelValue as MarkerIconSettings).icon, + color: this.modelValue.color.color, + trip: this.trip + }; + const markerIconShapesPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, MarkerIconShapesComponent, 'left', true, null, + ctx, + {}, + {}, {}, true); + markerIconShapesPopover.tbComponentRef.instance.popover = markerIconShapesPopover; + markerIconShapesPopover.tbComponentRef.instance.markerIconSelected.subscribe((iconInfo) => { + markerIconShapesPopover.hide(); + this.shapeSettingsFormGroup.get('iconContainer').patchValue( + iconInfo.iconContainer, {emitEvent: false} + ); + this.shapeSettingsFormGroup.get('icon').patchValue( + iconInfo.icon, {emitEvent: false} + ); + this.updateModel(); + }); + } + } + } + + private updateModel() { + this.modelValue = this.shapeSettingsFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + this.updatePreview(); + } + + private updatePreview() { + const color = this.modelValue.color.color; + if (this.markerType === MarkerType.shape) { + const shape = (this.modelValue as MarkerShapeSettings).shape; + this.shapePreview$ = createColorMarkerShapeURI(this.iconRegistry, this.domSanitizer, shape, tinycolor(color)).pipe( + map((url) => { + return this.domSanitizer.bypassSecurityTrustUrl(url); + }), + share() + ); + } else if (this.markerType === MarkerType.icon) { + const iconContainer = (this.modelValue as MarkerIconSettings).iconContainer; + const icon = (this.modelValue as MarkerIconSettings).icon; + this.iconPreview$ = createColorMarkerIconElement(this.iconRegistry, this.domSanitizer, iconContainer, icon, tinycolor(color)).pipe( + map((element) => { + return this.domSanitizer.bypassSecurityTrustHtml(element.outerHTML); + }), + share() + ); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.html new file mode 100644 index 0000000000..43ab2eb448 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.html @@ -0,0 +1,38 @@ + +
    +
    widgets.maps.data-layer.marker.marker-shapes
    +
    + + + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.scss new file mode 100644 index 0000000000..dac30dfc7c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.scss @@ -0,0 +1,39 @@ +/** + * 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. + */ +.tb-marker-shapes-panel { + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; + .tb-marker-shapes-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + button.mat-mdc-button-base.tb-select-shape-button { + width: 42px; + min-width: 42px; + height: 42px; + padding: 4px; + img.tb-marker-shape { + width: 34px; + height: 34px; + object-fit: contain; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.ts new file mode 100644 index 0000000000..0e4d9abb97 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.ts @@ -0,0 +1,93 @@ +/// +/// 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, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + createColorMarkerShapeURI, + MarkerShape, markerShapes, + tripMarkerShapes +} from '@shared/models/widget/maps/marker-shape.models'; +import { Observable } from 'rxjs'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { MatIconRegistry } from '@angular/material/icon'; +import tinycolor from 'tinycolor2'; +import { map, share } from 'rxjs/operators'; +import { coerceBoolean } from '@shared/decorators/coercion'; + +interface MarkerShapeInfo { + shape: MarkerShape; + url$: Observable; +} + +@Component({ + selector: 'tb-marker-shapes', + templateUrl: './marker-shapes.component.html', + providers: [], + styleUrls: ['./marker-shapes.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class MarkerShapesComponent extends PageComponent implements OnInit { + + @Input() + shape: MarkerShape; + + @Input() + color: string; + + @Input() + @coerceBoolean() + trip = false; + + @Input() + popover: TbPopoverComponent; + + @Output() + markerShapeSelected = new EventEmitter(); + + shapes: MarkerShapeInfo[]; + + constructor(protected store: Store, + private iconRegistry: MatIconRegistry, + private domSanitizer: DomSanitizer) { + super(store); + } + + ngOnInit(): void { + this.shapes = (this.trip ? tripMarkerShapes : markerShapes).map((shape) => { + return { + shape, + url$: createColorMarkerShapeURI(this.iconRegistry, this.domSanitizer, shape, tinycolor(this.color)).pipe( + map((url) => { + return this.domSanitizer.bypassSecurityTrustUrl(url); + }), + share() + ) + }; + }); + } + + cancel() { + this.popover?.hide(); + } + + selectShape(shape: MarkerShape) { + this.markerShapeSelected.emit(shape); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.html new file mode 100644 index 0000000000..9729c3559d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.html @@ -0,0 +1,81 @@ + + + +
    + + + + + {{ 'widgets.maps.timeline.control-panel' | translate }} + + + + +
    +
    widgets.maps.timeline.time-step
    + + + ms + +
    +
    +
    widgets.maps.timeline.speed-options
    + + + + {{ speed }} + + + +
    +
    + + {{ 'widgets.maps.timeline.timestamp' | translate }} + + +
    +
    + + + + + {{ 'widgets.maps.timeline.snap-to-real-location' | translate }} + + + + + + + + +
    +
    +
    +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.ts new file mode 100644 index 0000000000..5afeefcbe6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.ts @@ -0,0 +1,161 @@ +/// +/// 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, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + Validators +} from '@angular/forms'; +import { merge } from 'rxjs'; +import { WidgetService } from '@core/http/widget.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + TripTimelineSettings +} from '@shared/models/widget/maps/map.models'; + +@Component({ + selector: 'tb-trip-timeline-settings', + templateUrl: './trip-timeline-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TripTimelineSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TripTimelineSettingsComponent), + multi: true + } + ] +}) +export class TripTimelineSettingsComponent implements OnInit, ControlValueAccessor, Validator { + + settingsExpanded = false; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + @Input() + disabled: boolean; + + private modelValue: TripTimelineSettings; + + private propagateChange = null; + + public tripTimelineSettingsFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + + this.tripTimelineSettingsFormGroup = this.fb.group({ + showTimelineControl: [null], + timeStep: [null, [Validators.required, Validators.min(1)]], + speedOptions: [null, [Validators.required]], + showTimestamp: [null], + timestampFormat: [null, [Validators.required]], + snapToRealLocation: [null], + locationSnapFilter: [null, [Validators.required]] + }); + + this.tripTimelineSettingsFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + merge(this.tripTimelineSettingsFormGroup.get('showTimelineControl').valueChanges, + this.tripTimelineSettingsFormGroup.get('showTimestamp').valueChanges, + this.tripTimelineSettingsFormGroup.get('snapToRealLocation').valueChanges + ).pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateValidators(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.tripTimelineSettingsFormGroup.disable({emitEvent: false}); + } else { + this.tripTimelineSettingsFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: TripTimelineSettings): void { + this.modelValue = value; + this.tripTimelineSettingsFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updateValidators(); + this.settingsExpanded = this.tripTimelineSettingsFormGroup.get('showTimelineControl').value; + this.tripTimelineSettingsFormGroup.get('showTimelineControl').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((show) => { + this.settingsExpanded = show; + }); + } + + public validate(c: UntypedFormControl) { + const valid = this.tripTimelineSettingsFormGroup.valid; + return valid ? null : { + tripTimelineSettings: { + valid: false, + }, + }; + } + + private updateValidators() { + const showTimelineControl: boolean = this.tripTimelineSettingsFormGroup.get('showTimelineControl').value; + const showTimestamp: boolean = this.tripTimelineSettingsFormGroup.get('showTimestamp').value; + const snapToRealLocation: boolean = this.tripTimelineSettingsFormGroup.get('snapToRealLocation').value; + if (showTimelineControl) { + this.tripTimelineSettingsFormGroup.enable({emitEvent: false}); + if (!showTimestamp) { + this.tripTimelineSettingsFormGroup.get('timestampFormat').disable({emitEvent: false}); + } + if (!snapToRealLocation) { + this.tripTimelineSettingsFormGroup.get('locationSnapFilter').disable({emitEvent: false}); + } + } else { + this.tripTimelineSettingsFormGroup.disable({emitEvent: false}); + this.tripTimelineSettingsFormGroup.get('showTimelineControl').enable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = this.tripTimelineSettingsFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source-data-key.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source-data-key.component.ts index 9f36cc7076..6e8f1ea108 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source-data-key.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source-data-key.component.ts @@ -29,7 +29,7 @@ import { AppState } from '@core/core.state'; import { IAliasController } from '@core/api/widget-api.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { DataKey, Datasource, DatasourceType } from '@app/shared/models/widget.models'; -import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { ValueSourceConfig, ValueSourceType, 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 c73ef5eb57..045ce9a801 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 @@ -102,7 +102,7 @@ import { import { TimeSeriesChartThresholdRowComponent } from '@home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component'; -import { DataKeyInputComponent } from '@home/components/widget/lib/settings/common/data-key-input.component'; +import { DataKeyInputComponent } from '@home/components/widget/lib/settings/common/key/data-key-input.component'; import { EntityAliasInputComponent } from '@home/components/widget/lib/settings/common/entity-alias-input.component'; import { TimeSeriesChartThresholdSettingsPanelComponent @@ -183,6 +183,71 @@ import { import { DynamicFormArrayComponent } from '@home/components/widget/lib/settings/common/dynamic-form/dynamic-form-array.component'; +import { MapSettingsComponent } from '@home/components/widget/lib/settings/common/map/map-settings.component'; +import { MapLayersComponent } from '@home/components/widget/lib/settings/common/map/map-layers.component'; +import { MapLayerRowComponent } from '@home/components/widget/lib/settings/common/map/map-layer-row.component'; +import { + MapLayerSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/map/map-layer-settings-panel.component'; +import { MapDataLayersComponent } from '@home/components/widget/lib/settings/common/map/map-data-layers.component'; +import { MapDataLayerRowComponent } from '@home/components/widget/lib/settings/common/map/map-data-layer-row.component'; +import { + EntityAliasSelectComponent +} from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component'; +import { + MapDataLayerDialogComponent +} from '@home/components/widget/lib/settings/common/map/map-data-layer-dialog.component'; +import { FilterSelectComponent } from '@home/components/widget/lib/settings/common/filter/filter-select.component'; +import { DataKeysComponent } from '@home/components/widget/lib/settings/common/key/data-keys.component'; +import { + DataKeyConfigDialogComponent +} from '@home/components/widget/lib/settings/common/key/data-key-config-dialog.component'; +import { DataKeyConfigComponent } from '@home/components/widget/lib/settings/common/key/data-key-config.component'; +import { WidgetSettingsComponent } from '@home/components/widget/lib/settings/common/widget/widget-settings.component'; +import { + DataLayerColorSettingsComponent +} from '@home/components/widget/lib/settings/common/map/data-layer-color-settings.component'; +import { + DataLayerColorSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component'; +import { + MarkerImageSettingsComponent +} from '@home/components/widget/lib/settings/common/map/marker-image-settings.component'; +import { + MarkerImageSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/map/marker-image-settings-panel.component'; +import { + DataLayerPatternSettingsComponent +} from '@home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component'; +import { + MarkerShapeSettingsComponent +} from '@home/components/widget/lib/settings/common/map/marker-shape-settings.component'; +import { MarkerShapesComponent } from '@home/components/widget/lib/settings/common/map/marker-shapes.component'; +import { + MarkerClusteringSettingsComponent +} from '@home/components/widget/lib/settings/common/map/marker-clustering-settings.component'; +import { MapDataSourcesComponent } from '@home/components/widget/lib/settings/common/map/map-data-sources.component'; +import { + MapDataSourceRowComponent +} from '@home/components/widget/lib/settings/common/map/map-data-source-row.component'; +import { + ImageMapSourceSettingsComponent +} from '@home/components/widget/lib/settings/common/map/image-map-source-settings.component'; +import { + MapTooltipTagActionsComponent +} from '@home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component'; +import { + MapActionButtonsSettingsComponent +} from '@home/components/widget/lib/settings/common/map/map-action-buttons-settings.component'; +import { + MapActionButtonRowComponent +} from '@home/components/widget/lib/settings/common/map/map-action-button-row.component'; +import { + TripTimelineSettingsComponent +} from '@home/components/widget/lib/settings/common/map/trip-timeline-settings.component'; +import { + MarkerIconShapesComponent +} from '@home/components/widget/lib/settings/common/map/marker-icon-shapes.component'; @NgModule({ declarations: [ @@ -252,7 +317,36 @@ import { DynamicFormSelectItemsComponent, DynamicFormSelectItemRowComponent, DynamicFormComponent, - DynamicFormArrayComponent + DynamicFormArrayComponent, + MapLayerSettingsPanelComponent, + MapLayerRowComponent, + MapLayersComponent, + ImageMapSourceSettingsComponent, + DataLayerColorSettingsComponent, + DataLayerColorSettingsPanelComponent, + MapTooltipTagActionsComponent, + MapActionButtonsSettingsComponent, + MapActionButtonRowComponent, + DataLayerPatternSettingsComponent, + MarkerShapeSettingsComponent, + MarkerShapesComponent, + MarkerIconShapesComponent, + MarkerImageSettingsComponent, + MarkerImageSettingsPanelComponent, + MarkerClusteringSettingsComponent, + MapDataLayerDialogComponent, + MapDataLayerRowComponent, + MapDataLayersComponent, + MapDataSourceRowComponent, + MapDataSourcesComponent, + TripTimelineSettingsComponent, + MapSettingsComponent, + EntityAliasSelectComponent, + FilterSelectComponent, + DataKeysComponent, + DataKeyConfigDialogComponent, + DataKeyConfigComponent, + WidgetSettingsComponent ], imports: [ CommonModule, @@ -326,7 +420,14 @@ import { DynamicFormSelectItemsComponent, DynamicFormSelectItemRowComponent, DynamicFormComponent, - DynamicFormArrayComponent + DynamicFormArrayComponent, + MapSettingsComponent, + EntityAliasSelectComponent, + FilterSelectComponent, + DataKeysComponent, + DataKeyConfigDialogComponent, + DataKeyConfigComponent, + WidgetSettingsComponent ], providers: [ ColorSettingsComponentService, diff --git a/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html diff --git a/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.scss rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.scss diff --git a/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.ts similarity index 96% rename from ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.ts index e6a7a39a1f..fc28c72399 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.ts @@ -74,6 +74,9 @@ export class WidgetSettingsComponent implements ControlValueAccessor, OnDestroy, @Input() callbacks: WidgetConfigCallbacks; + @Input() + functionsOnly: boolean; + @Input() dashboard: Dashboard; @@ -139,6 +142,11 @@ export class WidgetSettingsComponent implements ControlValueAccessor, OnDestroy, this.definedSettingsComponent.dataKeyCallbacks = this.callbacks; } } + if (propName === 'functionsOnly') { + if (this.definedSettingsComponent) { + this.definedSettingsComponent.functionsOnly = this.functionsOnly; + } + } if (propName === 'widgetConfig') { if (this.definedSettingsComponent) { this.definedSettingsComponent.widgetConfig = this.widgetConfig; @@ -228,6 +236,7 @@ export class WidgetSettingsComponent implements ControlValueAccessor, OnDestroy, this.definedSettingsComponent = this.definedSettingsComponentRef.instance; this.definedSettingsComponent.aliasController = this.aliasController; this.definedSettingsComponent.callbacks = this.callbacks; + this.definedSettingsComponent.functionsOnly = this.functionsOnly; this.definedSettingsComponent.dataKeyCallbacks = this.callbacks; this.definedSettingsComponent.dashboard = this.dashboard; this.definedSettingsComponent.widget = this.widget; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html index 7db75f9968..8c12c81d60 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html @@ -15,11 +15,10 @@ limitations under the License. --> - {{ (keyType === dataKeyType.attribute ? attributeLabel diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html index 3a8f80ff2d..b3e0f21b83 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html @@ -43,15 +43,60 @@ {{ 'widgets.persistent-table.allow-delete-request' | translate }} -
    - +
    +
    widgets.table.pagination
    + {{ 'widgets.table.display-pagination' | translate }} - - widgets.table.default-page-size - - -
    +
    +
    {{ 'widgets.table.page-step-settings' | translate }}
    +
    +
    widgets.table.page-step-increment
    + + + + warning + + + +
    widgets.table.page-step-count
    + + + + warning + + +
    +
    +
    +
    widgets.table.default-page-size
    + + + + {{ size }} + + + +
    +
    widgets.table.default-sort-order diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts index 6281332a12..e1cb4f12f4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts @@ -27,6 +27,7 @@ import { MatChipInputEvent, MatChipGrid } from '@angular/material/chips'; import { MatAutocomplete, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { map, mergeMap, share, startWith } from 'rxjs/operators'; import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes'; +import { buildPageStepSizeValues } from '@home/components/widget/lib/table-widget.models'; interface DisplayColumn { name: string; @@ -57,6 +58,8 @@ export class PersistentTableWidgetSettingsComponent extends WidgetSettingsCompon persistentTableWidgetSettingsForm: UntypedFormGroup; + pageStepSizeValues = []; + filteredDisplayColumns: Observable>; columnSearchText = ''; @@ -94,6 +97,8 @@ export class PersistentTableWidgetSettingsComponent extends WidgetSettingsCompon displayPagination: true, defaultPageSize: 10, + pageStepIncrement: null, + pageStepCount: 3, defaultSortOrder: '-createdTime', displayColumns: ['rpcId', 'messageType', 'status', 'method', 'createdTime', 'expirationTime'] @@ -110,9 +115,15 @@ export class PersistentTableWidgetSettingsComponent extends WidgetSettingsCompon displayDetails: [settings.displayDetails, []], displayPagination: [settings.displayPagination, []], defaultPageSize: [settings.defaultPageSize, [Validators.min(1)]], + pageStepCount: [settings.pageStepCount ?? 3, [Validators.min(1), Validators.max(100), + Validators.required, Validators.pattern(/^\d*$/)]], + pageStepIncrement: [settings.pageStepIncrement ?? settings.defaultPageSize, + [Validators.min(1), Validators.required, Validators.pattern(/^\d*$/)]], defaultSortOrder: [settings.defaultSortOrder, []], displayColumns: [settings.displayColumns, [Validators.required]] }); + this.pageStepSizeValues = buildPageStepSizeValues(this.persistentTableWidgetSettingsForm.get('pageStepCount').value, + this.persistentTableWidgetSettingsForm.get('pageStepIncrement').value); } public validateSettings(): boolean { @@ -122,17 +133,26 @@ export class PersistentTableWidgetSettingsComponent extends WidgetSettingsCompon } protected validatorTriggers(): string[] { - return ['displayPagination']; + return ['displayPagination', 'pageStepCount', 'pageStepIncrement']; } - protected updateValidators(emitEvent: boolean) { + protected updateValidators(emitEvent: boolean, trigger: string) { + if (trigger === 'pageStepCount' || trigger === 'pageStepIncrement') { + this.persistentTableWidgetSettingsForm.get('defaultPageSize').reset(); + this.pageStepSizeValues = buildPageStepSizeValues(this.persistentTableWidgetSettingsForm.get('pageStepCount').value, + this.persistentTableWidgetSettingsForm.get('pageStepIncrement').value); + return; + } const displayPagination: boolean = this.persistentTableWidgetSettingsForm.get('displayPagination').value; if (displayPagination) { - this.persistentTableWidgetSettingsForm.get('defaultPageSize').enable(); + this.persistentTableWidgetSettingsForm.get('defaultPageSize').enable({emitEvent}); + this.persistentTableWidgetSettingsForm.get('pageStepCount').enable({emitEvent: false}); + this.persistentTableWidgetSettingsForm.get('pageStepIncrement').enable({emitEvent: false}); } else { - this.persistentTableWidgetSettingsForm.get('defaultPageSize').disable(); + this.persistentTableWidgetSettingsForm.get('defaultPageSize').disable({emitEvent}); + this.persistentTableWidgetSettingsForm.get('pageStepCount').disable({emitEvent: false}); + this.persistentTableWidgetSettingsForm.get('pageStepIncrement').disable({emitEvent: false}); } - this.persistentTableWidgetSettingsForm.get('defaultPageSize').updateValueAndValidity({emitEvent}); } private fetchColumns(searchText?: string): Observable> { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.html index 76aa5fd2f4..14383b9608 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.html @@ -59,6 +59,11 @@
    +
    + + {{ 'widgets.table.disable-sorting' | translate }} + +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.ts index 8572a9e348..89393ade34 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.ts @@ -47,7 +47,8 @@ export class EntitiesTableKeySettingsComponent extends WidgetSettingsComponent { useCellContentFunction: false, cellContentFunction: '', defaultColumnVisibility: 'visible', - columnSelectionToDisplay: 'enabled' + columnSelectionToDisplay: 'enabled', + disableSorting: false }; } @@ -61,6 +62,7 @@ export class EntitiesTableKeySettingsComponent extends WidgetSettingsComponent { cellContentFunction: [settings.cellContentFunction, [Validators.required]], defaultColumnVisibility: [settings.defaultColumnVisibility, []], columnSelectionToDisplay: [settings.columnSelectionToDisplay, []], + disableSorting: [settings.disableSorting, []] }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.html index 62ee969a52..b0be9fca0f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.html @@ -91,10 +91,52 @@ {{ 'widgets.table.display-pagination' | translate }} +
    +
    {{ 'widgets.table.page-step-settings' | translate }}
    +
    +
    widgets.table.page-step-increment
    + + + + warning + + + +
    widgets.table.page-step-count
    + + + + warning + + +
    +
    widgets.table.default-page-size
    - - + + + + {{ size }} + +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.ts index 9bb6b8c78a..c1bc327c3c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.ts @@ -19,6 +19,7 @@ import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.m import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; +import { buildPageStepSizeValues } from '@home/components/widget/lib/table-widget.models'; @Component({ selector: 'tb-entities-table-widget-settings', @@ -28,6 +29,7 @@ import { AppState } from '@core/core.state'; export class EntitiesTableWidgetSettingsComponent extends WidgetSettingsComponent { entitiesTableWidgetSettingsForm: UntypedFormGroup; + pageStepSizeValues = []; constructor(protected store: Store, private fb: UntypedFormBuilder) { @@ -54,6 +56,8 @@ export class EntitiesTableWidgetSettingsComponent extends WidgetSettingsComponen displayEntityType: true, displayPagination: true, defaultPageSize: 10, + pageStepIncrement: null, + pageStepCount: 3, defaultSortOrder: 'entityName', useRowStyleFunction: false, rowStyleFunction: '' @@ -76,45 +80,58 @@ export class EntitiesTableWidgetSettingsComponent extends WidgetSettingsComponen displayEntityType: [settings.displayEntityType, []], displayPagination: [settings.displayPagination, []], defaultPageSize: [settings.defaultPageSize, [Validators.min(1)]], + pageStepCount: [settings.pageStepCount ?? 3, [Validators.min(1), Validators.max(100), + Validators.required, Validators.pattern(/^\d*$/)]], + pageStepIncrement: [settings.pageStepIncrement ?? settings.defaultPageSize, + [Validators.min(1), Validators.required, Validators.pattern(/^\d*$/)]], defaultSortOrder: [settings.defaultSortOrder, []], useRowStyleFunction: [settings.useRowStyleFunction, []], rowStyleFunction: [settings.rowStyleFunction, [Validators.required]] }); + this.pageStepSizeValues = buildPageStepSizeValues(this.entitiesTableWidgetSettingsForm.get('pageStepCount').value, + this.entitiesTableWidgetSettingsForm.get('pageStepIncrement').value); } protected validatorTriggers(): string[] { - return ['useRowStyleFunction', 'displayPagination', 'displayEntityName', 'displayEntityLabel']; + return ['useRowStyleFunction', 'displayPagination', 'displayEntityName', 'displayEntityLabel', 'pageStepCount', + 'pageStepIncrement']; } - protected updateValidators(emitEvent: boolean) { + protected updateValidators(emitEvent: boolean, trigger: string) { + if (trigger === 'pageStepCount' || trigger === 'pageStepIncrement') { + this.entitiesTableWidgetSettingsForm.get('defaultPageSize').reset(); + this.pageStepSizeValues = buildPageStepSizeValues(this.entitiesTableWidgetSettingsForm.get('pageStepCount').value, + this.entitiesTableWidgetSettingsForm.get('pageStepIncrement').value); + return; + } const useRowStyleFunction: boolean = this.entitiesTableWidgetSettingsForm.get('useRowStyleFunction').value; const displayPagination: boolean = this.entitiesTableWidgetSettingsForm.get('displayPagination').value; const displayEntityName: boolean = this.entitiesTableWidgetSettingsForm.get('displayEntityName').value; const displayEntityLabel: boolean = this.entitiesTableWidgetSettingsForm.get('displayEntityLabel').value; if (useRowStyleFunction) { - this.entitiesTableWidgetSettingsForm.get('rowStyleFunction').enable(); + this.entitiesTableWidgetSettingsForm.get('rowStyleFunction').enable({emitEvent}); } else { - this.entitiesTableWidgetSettingsForm.get('rowStyleFunction').disable(); + this.entitiesTableWidgetSettingsForm.get('rowStyleFunction').disable({emitEvent}); } if (displayPagination) { - this.entitiesTableWidgetSettingsForm.get('defaultPageSize').enable(); + this.entitiesTableWidgetSettingsForm.get('defaultPageSize').enable({emitEvent}); + this.entitiesTableWidgetSettingsForm.get('pageStepCount').enable({emitEvent: false}); + this.entitiesTableWidgetSettingsForm.get('pageStepIncrement').enable({emitEvent: false}); } else { - this.entitiesTableWidgetSettingsForm.get('defaultPageSize').disable(); + this.entitiesTableWidgetSettingsForm.get('defaultPageSize').disable({emitEvent}); + this.entitiesTableWidgetSettingsForm.get('pageStepCount').disable({emitEvent: false}); + this.entitiesTableWidgetSettingsForm.get('pageStepIncrement').disable({emitEvent: false}); } if (displayEntityName) { - this.entitiesTableWidgetSettingsForm.get('entityNameColumnTitle').enable(); + this.entitiesTableWidgetSettingsForm.get('entityNameColumnTitle').enable({emitEvent}); } else { - this.entitiesTableWidgetSettingsForm.get('entityNameColumnTitle').disable(); + this.entitiesTableWidgetSettingsForm.get('entityNameColumnTitle').disable({emitEvent}); } if (displayEntityLabel) { - this.entitiesTableWidgetSettingsForm.get('entityLabelColumnTitle').enable(); + this.entitiesTableWidgetSettingsForm.get('entityLabelColumnTitle').enable({emitEvent}); } else { - this.entitiesTableWidgetSettingsForm.get('entityLabelColumnTitle').disable(); + this.entitiesTableWidgetSettingsForm.get('entityLabelColumnTitle').disable({emitEvent}); } - this.entitiesTableWidgetSettingsForm.get('rowStyleFunction').updateValueAndValidity({emitEvent}); - this.entitiesTableWidgetSettingsForm.get('defaultPageSize').updateValueAndValidity({emitEvent}); - this.entitiesTableWidgetSettingsForm.get('entityNameColumnTitle').updateValueAndValidity({emitEvent}); - this.entitiesTableWidgetSettingsForm.get('entityLabelColumnTitle').updateValueAndValidity({emitEvent}); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.ts index 31b5fb63f1..dd9ca73b7a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.ts @@ -22,7 +22,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { TranslateService } from '@ngx-translate/core'; import { IAliasController } from '@core/api/widget-api.models'; -import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { Datasource } from '@shared/models/widget.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/circle-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/circle-settings.component.html similarity index 97% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/circle-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/circle-settings.component.html index 49605f4ced..54f9bf4788 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/circle-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/circle-settings.component.html @@ -66,7 +66,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.circle-label-function' | translate }}" - helpId="widget/lib/map/label_fn"> + helpId="widget/lib/map-legacy/label_fn"> @@ -110,7 +110,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.circle-tooltip-function' | translate }}" - helpId="widget/lib/map/polygon_tooltip_fn"> + helpId="widget/lib/map-legacy/polygon_tooltip_fn"> @@ -146,7 +146,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.circle-fill-color-function' | translate }}" - helpId="widget/lib/map/polygon_color_fn"> + helpId="widget/lib/map-legacy/polygon_color_fn"> @@ -186,7 +186,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.circle-stroke-color-function' | translate }}" - helpId="widget/lib/map/polygon_color_fn"> + helpId="widget/lib/map-legacy/polygon_color_fn"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/circle-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/circle-settings.component.ts similarity index 98% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/circle-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/circle-settings.component.ts index e4e41bf79c..c8761736bc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/circle-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/circle-settings.component.ts @@ -33,7 +33,7 @@ import { CircleSettings, ShowTooltipAction, showTooltipActionTranslationMap -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { WidgetService } from '@core/http/widget.service'; import { Widget } from '@shared/models/widget.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -41,7 +41,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-circle-settings', templateUrl: './circle-settings.component.html', - styleUrls: ['./../widget-settings.scss'], + styleUrls: ['./../../widget-settings.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/common-map-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/common-map-settings.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/common-map-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/common-map-settings.component.html diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/common-map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/common-map-settings.component.ts similarity index 98% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/common-map-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/common-map-settings.component.ts index 6bd2e937d2..f777fec3b9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/common-map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/common-map-settings.component.ts @@ -29,14 +29,14 @@ import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { TranslateService } from '@ngx-translate/core'; -import { CommonMapSettings, MapProviders } from '@home/components/widget/lib/maps/map-models'; +import { CommonMapSettings, MapProviders } from '@home/components/widget/lib/maps-legacy/map-models'; import { Widget } from '@shared/models/widget.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-common-map-settings', templateUrl: './common-map-settings.component.html', - styleUrls: ['./../widget-settings.scss'], + styleUrls: ['./../../widget-settings.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/datasources-key-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/datasources-key-autocomplete.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/datasources-key-autocomplete.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/datasources-key-autocomplete.component.html diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/datasources-key-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/datasources-key-autocomplete.component.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/datasources-key-autocomplete.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/datasources-key-autocomplete.component.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/google-map-provider-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/google-map-provider-settings.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/google-map-provider-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/google-map-provider-settings.component.html diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/google-map-provider-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/google-map-provider-settings.component.ts similarity index 97% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/google-map-provider-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/google-map-provider-settings.component.ts index b7d3a78514..d1b3cf2189 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/google-map-provider-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/google-map-provider-settings.component.ts @@ -33,13 +33,13 @@ import { GoogleMapProviderSettings, GoogleMapType, googleMapTypeProviderTranslationMap -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-google-map-provider-settings', templateUrl: './google-map-provider-settings.component.html', - styleUrls: ['./../widget-settings.scss'], + styleUrls: ['./../../widget-settings.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/here-map-provider-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/here-map-provider-settings.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/here-map-provider-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/here-map-provider-settings.component.html diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/here-map-provider-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/here-map-provider-settings.component.ts similarity index 97% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/here-map-provider-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/here-map-provider-settings.component.ts index febcaefad8..5fa9a01342 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/here-map-provider-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/here-map-provider-settings.component.ts @@ -33,14 +33,14 @@ import { HereMapProvider, HereMapProviderSettings, hereMapProviderTranslationMap -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { isDefinedAndNotNull } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-here-map-provider-settings', templateUrl: './here-map-provider-settings.component.html', - styleUrls: ['./../widget-settings.scss'], + styleUrls: ['./../../widget-settings.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/image-map-provider-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/image-map-provider-settings.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/image-map-provider-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/image-map-provider-settings.component.html diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/image-map-provider-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/image-map-provider-settings.component.ts similarity index 99% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/image-map-provider-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/image-map-provider-settings.component.ts index 6ffe958c0a..747a339297 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/image-map-provider-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/image-map-provider-settings.component.ts @@ -28,7 +28,7 @@ import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { TranslateService } from '@ngx-translate/core'; -import { ImageMapProviderSettings } from '@home/components/widget/lib/maps/map-models'; +import { ImageMapProviderSettings } from '@home/components/widget/lib/maps-legacy/map-models'; import { IAliasController } from '@core/api/widget-api.models'; import { Observable, of } from 'rxjs'; import { catchError, map, mergeMap, publishReplay, refCount, startWith, tap } from 'rxjs/operators'; @@ -40,7 +40,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-image-map-provider-settings', templateUrl: './image-map-provider-settings.component.html', - styleUrls: ['./../widget-settings.scss'], + styleUrls: ['./../../widget-settings.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-editor-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-editor-settings.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-editor-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-editor-settings.component.html diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-editor-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-editor-settings.component.ts similarity index 98% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-editor-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-editor-settings.component.ts index 815768e1ac..b63f3d68c1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-editor-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-editor-settings.component.ts @@ -28,14 +28,14 @@ import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { TranslateService } from '@ngx-translate/core'; -import { MapEditorSettings } from '@home/components/widget/lib/maps/map-models'; +import { MapEditorSettings } from '@home/components/widget/lib/maps-legacy/map-models'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-map-editor-settings', templateUrl: './map-editor-settings.component.html', - styleUrls: ['./../widget-settings.scss'], + styleUrls: ['./../../widget-settings.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-provider-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-provider-settings.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-provider-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-provider-settings.component.html diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-provider-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-provider-settings.component.ts similarity index 98% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-provider-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-provider-settings.component.ts index d7cec5f898..23cbec5d51 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-provider-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-provider-settings.component.ts @@ -41,7 +41,7 @@ import { mapProviderTranslationMap, OpenStreetMapProviderSettings, TencentMapProviderSettings -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { extractType } from '@core/utils'; import { IAliasController } from '@core/api/widget-api.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -49,7 +49,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-map-provider-settings', templateUrl: './map-provider-settings.component.html', - styleUrls: ['./../widget-settings.scss'], + styleUrls: ['./../../widget-settings.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-settings-legacy.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-settings-legacy.component.html diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-settings-legacy.component.ts similarity index 94% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-settings-legacy.component.ts index 961423a1ba..8af3d53e00 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-settings-legacy.component.ts @@ -46,30 +46,30 @@ import { PolygonSettings, PolylineSettings, UnitedMapSettings -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { extractType } from '@core/utils'; import { IAliasController } from '@core/api/widget-api.models'; import { Widget } from '@shared/models/widget.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-map-settings', - templateUrl: './map-settings.component.html', - styleUrls: ['./../widget-settings.scss'], + selector: 'tb-map-settings-legacy', + templateUrl: './map-settings-legacy.component.html', + styleUrls: ['./../../widget-settings.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MapSettingsComponent), + useExisting: forwardRef(() => MapSettingsLegacyComponent), multi: true }, { provide: NG_VALIDATORS, - useExisting: forwardRef(() => MapSettingsComponent), + useExisting: forwardRef(() => MapSettingsLegacyComponent), multi: true } ] }) -export class MapSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { +export class MapSettingsLegacyComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { @Input() disabled: boolean; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-widget-settings-legacy.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-widget-settings-legacy.component.html new file mode 100644 index 0000000000..717cc48073 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-widget-settings-legacy.component.html @@ -0,0 +1,24 @@ + +
    + + +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-widget-settings-legacy.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-widget-settings-legacy.component.ts new file mode 100644 index 0000000000..8089313f18 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-widget-settings-legacy.component.ts @@ -0,0 +1,63 @@ +/// +/// 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 } from '@angular/core'; +import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { defaultMapSettings } from 'src/app/modules/home/components/widget/lib/maps-legacy/map-models'; + +@Component({ + selector: 'tb-map-widget-settings-legacy', + templateUrl: './map-widget-settings-legacy.component.html', + styleUrls: ['./../../widget-settings.scss'] +}) +export class MapWidgetSettingsLegacyComponent extends WidgetSettingsComponent { + + mapWidgetSettingsForm: UntypedFormGroup; + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.mapWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return { + ...defaultMapSettings + }; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.mapWidgetSettingsForm = this.fb.group({ + mapSettings: [settings.mapSettings, []] + }); + } + + protected prepareInputSettings(settings: WidgetSettings): WidgetSettings { + return { + mapSettings: settings + }; + } + + protected prepareOutputSettings(settings: any): WidgetSettings { + return settings.mapSettings; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/marker-clustering-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/marker-clustering-settings.component.html similarity index 98% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/marker-clustering-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/marker-clustering-settings.component.html index e3201cb4c5..21b4f19e8d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/marker-clustering-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/marker-clustering-settings.component.html @@ -82,7 +82,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'childCount']" functionTitle="{{ 'widgets.maps.marker-color-function' | translate }}" - helpId="widget/lib/map/clustering_color_fn"> + helpId="widget/lib/map-legacy/clustering_color_fn"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/marker-clustering-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/marker-clustering-settings.component.ts similarity index 98% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/marker-clustering-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/marker-clustering-settings.component.ts index a467ad793d..e317a21ad9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/marker-clustering-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/marker-clustering-settings.component.ts @@ -29,14 +29,14 @@ import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { TranslateService } from '@ngx-translate/core'; -import { MarkerClusteringSettings } from '@home/components/widget/lib/maps/map-models'; +import { MarkerClusteringSettings } from '@home/components/widget/lib/maps-legacy/map-models'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-marker-clustering-settings', templateUrl: './marker-clustering-settings.component.html', - styleUrls: ['./../widget-settings.scss'], + styleUrls: ['./../../widget-settings.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/markers-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/markers-settings.component.html similarity index 96% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/markers-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/markers-settings.component.html index 99b4f0fa29..4111a22987 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/markers-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/markers-settings.component.html @@ -35,7 +35,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['origXPos', 'origYPos', 'data', 'dsData', 'dsIndex', 'aspect']" functionTitle="{{ 'widgets.maps.position-function' | translate }}" - helpId="widget/lib/map/position_fn"> + helpId="widget/lib/map-legacy/position_fn"> {{ 'widgets.maps.draggable-marker' | translate }} @@ -68,7 +68,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.label-function' | translate }}" - helpId="widget/lib/map/label_fn"> + helpId="widget/lib/map-legacy/label_fn"> @@ -112,7 +112,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.tooltip-function' | translate }}" - helpId="widget/lib/map/tooltip_fn"> + helpId="widget/lib/map-legacy/tooltip_fn">
    @@ -151,7 +151,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.color-function' | translate }}" - helpId="widget/lib/map/color_fn"> + helpId="widget/lib/map-legacy/color_fn"> @@ -185,7 +185,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'images', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.marker-image-function' | translate }}" - helpId="widget/lib/map/marker_image_fn"> + helpId="widget/lib/map-legacy/marker_image_fn"> + helpId="widget/lib/map-legacy/label_fn"> @@ -110,7 +110,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.polygon-tooltip-function' | translate }}" - helpId="widget/lib/map/polygon_tooltip_fn"> + helpId="widget/lib/map-legacy/polygon_tooltip_fn"> @@ -146,7 +146,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.polygon-color-function' | translate }}" - helpId="widget/lib/map/polygon_color_fn"> + helpId="widget/lib/map-legacy/polygon_color_fn"> @@ -186,7 +186,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.polygon-stroke-color-function' | translate }}" - helpId="widget/lib/map/polygon_color_fn"> + helpId="widget/lib/map-legacy/polygon_color_fn"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/polygon-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/polygon-settings.component.ts similarity index 98% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/polygon-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/polygon-settings.component.ts index f68e121393..62e80e0eec 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/polygon-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/polygon-settings.component.ts @@ -33,7 +33,7 @@ import { PolygonSettings, ShowTooltipAction, showTooltipActionTranslationMap -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { WidgetService } from '@core/http/widget.service'; import { Widget } from '@shared/models/widget.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -41,7 +41,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-polygon-settings', templateUrl: './polygon-settings.component.html', - styleUrls: ['./../widget-settings.scss'], + styleUrls: ['./../../widget-settings.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-settings.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-settings.component.html diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-settings.component.ts similarity index 98% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-settings.component.ts index 1d8ca0454b..322eb0837e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-settings.component.ts @@ -28,14 +28,14 @@ import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { TranslateService } from '@ngx-translate/core'; -import { PolylineSettings } from '@home/components/widget/lib/maps/map-models'; +import { PolylineSettings } from '@home/components/widget/lib/maps-legacy/map-models'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-route-map-settings', templateUrl: './route-map-settings.component.html', - styleUrls: ['./../widget-settings.scss'], + styleUrls: ['./../../widget-settings.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-widget-settings.component.html similarity index 92% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-widget-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-widget-settings.component.html index 206933cb91..a6e4dc9293 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-widget-settings.component.html @@ -16,9 +16,9 @@ -->
    - - +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-widget-settings.component.ts similarity index 95% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-widget-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-widget-settings.component.ts index 545a0d9c98..0819253feb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-widget-settings.component.ts @@ -19,12 +19,12 @@ import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.m import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { defaultMapSettings } from 'src/app/modules/home/components/widget/lib/maps/map-models'; +import { defaultMapSettings } from 'src/app/modules/home/components/widget/lib/maps-legacy/map-models'; @Component({ selector: 'tb-route-map-widget-settings', templateUrl: './route-map-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + styleUrls: ['./../../widget-settings.scss'] }) export class RouteMapWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/tencent-map-provider-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/tencent-map-provider-settings.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/tencent-map-provider-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/tencent-map-provider-settings.component.html diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/tencent-map-provider-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/tencent-map-provider-settings.component.ts similarity index 97% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/tencent-map-provider-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/tencent-map-provider-settings.component.ts index ccde0cdafc..ee47caa385 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/tencent-map-provider-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/tencent-map-provider-settings.component.ts @@ -33,13 +33,13 @@ import { TencentMapProviderSettings, TencentMapType, tencentMapTypeProviderTranslationMap -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-tencent-map-provider-settings', templateUrl: './tencent-map-provider-settings.component.html', - styleUrls: ['./../widget-settings.scss'], + styleUrls: ['./../../widget-settings.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-common-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-common-settings.component.html similarity index 98% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-common-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-common-settings.component.html index 1ac03e042a..59393ef944 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-common-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-common-settings.component.html @@ -85,7 +85,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.tooltip-function' | translate }}" - helpId="widget/lib/map/tooltip_fn"> + helpId="widget/lib/map-legacy/tooltip_fn"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-common-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-common-settings.component.ts similarity index 98% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-common-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-common-settings.component.ts index 1a38c1e041..018b051080 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-common-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-common-settings.component.ts @@ -29,7 +29,7 @@ import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { TranslateService } from '@ngx-translate/core'; -import { TripAnimationCommonSettings } from '@home/components/widget/lib/maps/map-models'; +import { TripAnimationCommonSettings } from '@home/components/widget/lib/maps-legacy/map-models'; import { Widget } from '@shared/models/widget.models'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -37,7 +37,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-trip-animation-common-settings', templateUrl: './trip-animation-common-settings.component.html', - styleUrls: ['./../widget-settings.scss'], + styleUrls: ['./../../widget-settings.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-marker-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-marker-settings.component.html similarity index 97% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-marker-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-marker-settings.component.html index b869d12fb8..dfb2dd8ce5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-marker-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-marker-settings.component.html @@ -50,7 +50,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.label-function' | translate }}" - helpId="widget/lib/map/label_fn"> + helpId="widget/lib/map-legacy/label_fn"> @@ -84,7 +84,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'images', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.marker-image-function' | translate }}" - helpId="widget/lib/map/marker_image_fn"> + helpId="widget/lib/map-legacy/marker_image_fn"> + helpId="widget/lib/map-legacy/path_color_fn"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-path-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-path-settings.component.ts similarity index 98% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-path-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-path-settings.component.ts index f540c2cd21..8936d9b1aa 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-path-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-path-settings.component.ts @@ -32,14 +32,14 @@ import { PolylineDecoratorSymbol, polylineDecoratorSymbolTranslationMap, PolylineSettings -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-trip-animation-path-settings', templateUrl: './trip-animation-path-settings.component.html', - styleUrls: ['./../widget-settings.scss'], + styleUrls: ['./../../widget-settings.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-point-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-point-settings.component.html similarity index 96% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-point-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-point-settings.component.html index ea7b2d77ca..d98a88f6f1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-point-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-point-settings.component.html @@ -59,7 +59,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.point-color-function' | translate }}" - helpId="widget/lib/map/path_point_color_fn"> + helpId="widget/lib/map-legacy/path_point_color_fn"> @@ -80,7 +80,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.point-as-anchor-function' | translate }}" - helpId="widget/lib/map/trip_point_as_anchor_fn"> + helpId="widget/lib/map-legacy/trip_point_as_anchor_fn"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-point-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-point-settings.component.ts similarity index 98% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-point-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-point-settings.component.ts index 02d8601bb6..fe0fc91ba5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-point-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-point-settings.component.ts @@ -33,14 +33,14 @@ import { PolylineDecoratorSymbol, polylineDecoratorSymbolTranslationMap, PolylineSettings -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-trip-animation-point-settings', templateUrl: './trip-animation-point-settings.component.html', - styleUrls: ['./../widget-settings.scss'], + styleUrls: ['./../../widget-settings.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-widget-settings.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-widget-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-widget-settings.component.html diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-widget-settings.component.ts similarity index 97% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-widget-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-widget-settings.component.ts index c33ebc6be7..85be370ada 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-widget-settings.component.ts @@ -35,13 +35,13 @@ import { PolylineSettings, TripAnimationCommonSettings, TripAnimationMarkerSettings -} from 'src/app/modules/home/components/widget/lib/maps/map-models'; +} from 'src/app/modules/home/components/widget/lib/maps-legacy/map-models'; import { extractType } from '@core/utils'; @Component({ selector: 'tb-trip-animation-widget-settings', templateUrl: './trip-animation-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + styleUrls: ['./../../widget-settings.scss'] }) export class TripAnimationWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.html index 0065c0d0a1..f58e1d65ab 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.html @@ -15,10 +15,27 @@ limitations under the License. --> -
    - + + -
    +
    +
    widget-config.card-appearance
    +
    +
    {{ 'widgets.background.background' | translate }}
    + + +
    +
    +
    {{ 'widget-config.card-padding' | translate }}
    + + + +
    +
    + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts index f5bb9b64d0..221f820d68 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts @@ -19,7 +19,9 @@ import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.m import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { defaultMapSettings } from 'src/app/modules/home/components/widget/lib/maps/map-models'; +import { isDefinedAndNotNull, mergeDeepIgnoreArray } from '@core/utils'; +import { mapWidgetDefaultSettings, MapWidgetSettings } from '@home/components/widget/lib/maps/map-widget.models'; +import { WidgetConfigComponentData } from '@home/models/widget-component.models'; @Component({ selector: 'tb-map-widget-settings', @@ -30,6 +32,8 @@ export class MapWidgetSettingsComponent extends WidgetSettingsComponent { mapWidgetSettingsForm: UntypedFormGroup; + trip = false; + constructor(protected store: Store, private fb: UntypedFormBuilder) { super(store); @@ -39,25 +43,26 @@ export class MapWidgetSettingsComponent extends WidgetSettingsComponent { return this.mapWidgetSettingsForm; } + protected onWidgetConfigSet(widgetConfig: WidgetConfigComponentData) { + const params = widgetConfig.typeParameters as any; + if (isDefinedAndNotNull(params.trip)) { + this.trip = params.trip === true; + } + } + protected defaultSettings(): WidgetSettings { - return { - ...defaultMapSettings - }; + return mergeDeepIgnoreArray({} as MapWidgetSettings, mapWidgetDefaultSettings); } protected onSettingsSet(settings: WidgetSettings) { this.mapWidgetSettingsForm = this.fb.group({ - mapSettings: [settings.mapSettings, []] + mapSettings: [settings, []], + background: [settings.background, []], + padding: [settings.padding, []] }); } - protected prepareInputSettings(settings: WidgetSettings): WidgetSettings { - return { - mapSettings: settings - }; - } - protected prepareOutputSettings(settings: any): WidgetSettings { - return settings.mapSettings; + return {...settings.mapSettings, background: settings.background, padding: settings.padding}; } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts index dfc606a380..fbf9b2eec2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts @@ -210,52 +210,52 @@ import { } from '@home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component'; import { OpenStreetMapProviderSettingsComponent -} from '@home/components/widget/lib/settings/map/openstreet-map-provider-settings.component'; -import { MapProviderSettingsComponent } from '@home/components/widget/lib/settings/map/map-provider-settings.component'; -import { MapSettingsComponent } from '@home/components/widget/lib/settings/map/map-settings.component'; -import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings/map/map-widget-settings.component'; +} from '@home/components/widget/lib/settings/map/legacy/openstreet-map-provider-settings.component'; +import { MapProviderSettingsComponent } from '@home/components/widget/lib/settings/map/legacy/map-provider-settings.component'; +import { MapSettingsLegacyComponent } from '@home/components/widget/lib/settings/map/legacy/map-settings-legacy.component'; +import { MapWidgetSettingsLegacyComponent } from '@home/components/widget/lib/settings/map/legacy/map-widget-settings-legacy.component'; import { GoogleMapProviderSettingsComponent -} from '@home/components/widget/lib/settings/map/google-map-provider-settings.component'; +} from '@home/components/widget/lib/settings/map/legacy/google-map-provider-settings.component'; import { HereMapProviderSettingsComponent -} from '@home/components/widget/lib/settings/map/here-map-provider-settings.component'; +} from '@home/components/widget/lib/settings/map/legacy/here-map-provider-settings.component'; import { TencentMapProviderSettingsComponent -} from '@home/components/widget/lib/settings/map/tencent-map-provider-settings.component'; +} from '@home/components/widget/lib/settings/map/legacy/tencent-map-provider-settings.component'; import { ImageMapProviderSettingsComponent -} from '@home/components/widget/lib/settings/map/image-map-provider-settings.component'; +} from '@home/components/widget/lib/settings/map/legacy/image-map-provider-settings.component'; import { DatasourcesKeyAutocompleteComponent -} from '@home/components/widget/lib/settings/map/datasources-key-autocomplete.component'; -import { CommonMapSettingsComponent } from '@home/components/widget/lib/settings/map/common-map-settings.component'; -import { MarkersSettingsComponent } from '@home/components/widget/lib/settings/map/markers-settings.component'; -import { PolygonSettingsComponent } from '@home/components/widget/lib/settings/map/polygon-settings.component'; -import { CircleSettingsComponent } from '@home/components/widget/lib/settings/map/circle-settings.component'; +} from '@home/components/widget/lib/settings/map/legacy/datasources-key-autocomplete.component'; +import { CommonMapSettingsComponent } from '@home/components/widget/lib/settings/map/legacy/common-map-settings.component'; +import { MarkersSettingsComponent } from '@home/components/widget/lib/settings/map/legacy/markers-settings.component'; +import { PolygonSettingsComponent } from '@home/components/widget/lib/settings/map/legacy/polygon-settings.component'; +import { CircleSettingsComponent } from '@home/components/widget/lib/settings/map/legacy/circle-settings.component'; import { MarkerClusteringSettingsComponent -} from '@home/components/widget/lib/settings/map/marker-clustering-settings.component'; -import { MapEditorSettingsComponent } from '@home/components/widget/lib/settings/map/map-editor-settings.component'; -import { RouteMapSettingsComponent } from '@home/components/widget/lib/settings/map/route-map-settings.component'; +} from '@home/components/widget/lib/settings/map/legacy/marker-clustering-settings.component'; +import { MapEditorSettingsComponent } from '@home/components/widget/lib/settings/map/legacy/map-editor-settings.component'; +import { RouteMapSettingsComponent } from '@home/components/widget/lib/settings/map/legacy/route-map-settings.component'; import { RouteMapWidgetSettingsComponent -} from '@home/components/widget/lib/settings/map/route-map-widget-settings.component'; +} from '@home/components/widget/lib/settings/map/legacy/route-map-widget-settings.component'; import { TripAnimationWidgetSettingsComponent -} from '@home/components/widget/lib/settings/map/trip-animation-widget-settings.component'; +} from '@home/components/widget/lib/settings/map/legacy/trip-animation-widget-settings.component'; import { TripAnimationCommonSettingsComponent -} from '@home/components/widget/lib/settings/map/trip-animation-common-settings.component'; +} from '@home/components/widget/lib/settings/map/legacy/trip-animation-common-settings.component'; import { TripAnimationMarkerSettingsComponent -} from '@home/components/widget/lib/settings/map/trip-animation-marker-settings.component'; +} from '@home/components/widget/lib/settings/map/legacy/trip-animation-marker-settings.component'; import { TripAnimationPathSettingsComponent -} from '@home/components/widget/lib/settings/map/trip-animation-path-settings.component'; +} from '@home/components/widget/lib/settings/map/legacy/trip-animation-path-settings.component'; import { TripAnimationPointSettingsComponent -} from '@home/components/widget/lib/settings/map/trip-animation-point-settings.component'; +} from '@home/components/widget/lib/settings/map/legacy/trip-animation-point-settings.component'; import { GatewayLogsSettingsComponent } from '@home/components/widget/lib/settings/gateway/gateway-logs-settings.component'; @@ -374,6 +374,7 @@ import { import { ValueStepperWidgetSettingsComponent } from '@home/components/widget/lib/settings/control/value-stepper-widget-settings.component'; +import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings/map/map-widget-settings.component'; @NgModule({ declarations: [ @@ -461,12 +462,12 @@ import { MarkerClusteringSettingsComponent, MapEditorSettingsComponent, RouteMapSettingsComponent, - MapSettingsComponent, + MapSettingsLegacyComponent, TripAnimationCommonSettingsComponent, TripAnimationMarkerSettingsComponent, TripAnimationPathSettingsComponent, TripAnimationPointSettingsComponent, - MapWidgetSettingsComponent, + MapWidgetSettingsLegacyComponent, RouteMapWidgetSettingsComponent, GatewayLogsSettingsComponent, GatewayServiceRPCSettingsComponent, @@ -506,7 +507,8 @@ import { LabelCardWidgetSettingsComponent, LabelValueCardWidgetSettingsComponent, UnreadNotificationWidgetSettingsComponent, - ScadaSymbolWidgetSettingsComponent + ScadaSymbolWidgetSettingsComponent, + MapWidgetSettingsComponent ], imports: [ CommonModule, @@ -599,12 +601,12 @@ import { MarkerClusteringSettingsComponent, MapEditorSettingsComponent, RouteMapSettingsComponent, - MapSettingsComponent, + MapSettingsLegacyComponent, TripAnimationCommonSettingsComponent, TripAnimationMarkerSettingsComponent, TripAnimationPathSettingsComponent, TripAnimationPointSettingsComponent, - MapWidgetSettingsComponent, + MapWidgetSettingsLegacyComponent, RouteMapWidgetSettingsComponent, GatewayLogsSettingsComponent, GatewayServiceRPCSettingsComponent, @@ -644,7 +646,8 @@ import { LabelCardWidgetSettingsComponent, LabelValueCardWidgetSettingsComponent, UnreadNotificationWidgetSettingsComponent, - ScadaSymbolWidgetSettingsComponent + ScadaSymbolWidgetSettingsComponent, + MapWidgetSettingsComponent ] }) export class WidgetSettingsModule { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts index 47ae0b8cc8..8aadc62ee4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts @@ -16,7 +16,7 @@ import { EntityId } from '@shared/models/id/entity-id'; import { DataKey, FormattedData, WidgetActionDescriptor, WidgetConfig } from '@shared/models/widget.models'; -import { getDescendantProp, isDefined, isNotEmptyStr } from '@core/utils'; +import { getDescendantProp, isDefined, isDefinedAndNotNull, isNotEmptyStr } from '@core/utils'; import { AlarmDataInfo, alarmFields } from '@shared/models/alarm.models'; import tinycolor from 'tinycolor2'; import { Direction } from '@shared/models/page/sort-order'; @@ -47,6 +47,8 @@ export interface TableWidgetSettings { enableStickyHeader: boolean; displayPagination: boolean; defaultPageSize: number; + pageStepIncrement: number; + pageStepCount: number; useRowStyleFunction: boolean; rowStyleFunction?: TbFunction; reserveSpaceForHiddenAction?: boolean; @@ -61,6 +63,7 @@ export interface TableWidgetDataKeySettings { cellContentFunction?: TbFunction; defaultColumnVisibility?: ColumnVisibilityOptions; columnSelectionToDisplay?: ColumnSelectionOptions; + disableSorting?: boolean; } export type ShowCellButtonActionFunction = (ctx: WidgetContext, data: EntityData | AlarmDataInfo | FormattedData) => boolean; @@ -145,7 +148,7 @@ export function entityDataSortOrderFromString(strSortOrder: string, columns: Ent if (!column) { column = findColumnByName(property, columns); } - if (column && column.entityKey) { + if (column && column.entityKey && column.sortable) { return {key: column.entityKey, direction}; } return null; @@ -558,3 +561,14 @@ export function getHeaderTitle(dataKey: DataKey, keySettings: TableWidgetDataKey } return dataKey.label; } + +export function buildPageStepSizeValues(pageStepCount: number, pageStepIncrement: number): Array { + const pageSteps: Array = []; + if (isDefinedAndNotNull(pageStepCount) && pageStepCount > 0 && pageStepCount <= 100 && + isDefinedAndNotNull(pageStepIncrement) && pageStepIncrement > 0) { + for (let i = 1; i <= pageStepCount; i++) { + pageSteps.push(pageStepIncrement * i); + } + } + return pageSteps; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts index 0fda3b9afe..9544947060 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts @@ -173,7 +173,7 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI public enableStickyHeader = true; public enableStickyAction = true; public showCellActionsMenu = true; - public pageSizeOptions; + public pageSizeOptions = []; public textSearchMode = false; public hidePageSize = false; public sources: TimeseriesTableSource[]; @@ -192,7 +192,7 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI private latestData: Array; private datasources: Array; - private defaultPageSize = 10; + private defaultPageSize; private defaultSortOrder = '-0'; private hideEmptyLines = false; public showTimestamp = true; @@ -352,10 +352,25 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'rowData, ctx'); const pageSize = this.settings.defaultPageSize; + let pageStepIncrement = this.settings.pageStepIncrement; + let pageStepCount = this.settings.pageStepCount; + if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { this.defaultPageSize = pageSize; } - this.pageSizeOptions = [this.defaultPageSize, this.defaultPageSize * 2, this.defaultPageSize * 3]; + + if (!this.defaultPageSize) { + this.defaultPageSize = pageStepIncrement ?? 10; + } + + if (!isDefinedAndNotNull(pageStepIncrement) || !isDefinedAndNotNull(pageStepCount)) { + pageStepIncrement = this.defaultPageSize; + pageStepCount = 3; + } + + for (let i = 1; i <= pageStepCount; i++) { + this.pageSizeOptions.push(pageStepIncrement * i); + } this.noDataDisplayMessageText = noDataMessage(this.widgetConfig.noDataDisplayMessage, 'widget.no-data-found', this.utils, this.translate); @@ -518,8 +533,8 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI const latestDataKeys = datasource.latestDataKeys; let header: TimeseriesHeader[] = []; dataKeys.forEach((dataKey, index) => { - const sortable = !dataKey.usePostProcessing; const keySettings: TableWidgetDataKeySettings = dataKey.settings; + const sortable = !keySettings.disableSorting && !dataKey.usePostProcessing; const styleInfo = getCellStyleInfo(this.ctx, keySettings, 'value, rowData, ctx'); const contentFunctionInfo = getCellContentFunctionInfo(this.ctx, keySettings, 'value, rowData, ctx'); const columnDefaultVisibility = getColumnDefaultVisibility(keySettings, this.ctx); @@ -544,8 +559,8 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI if (latestDataKeys) { latestDataKeys.forEach((dataKey, latestIndex) => { const index = dataKeys.length + latestIndex; - const sortable = !dataKey.usePostProcessing; const keySettings: TimeseriesWidgetLatestDataKeySettings = dataKey.settings; + const sortable = !keySettings.disableSorting && !dataKey.usePostProcessing; const styleInfo = getCellStyleInfo(this.ctx, keySettings, 'value, rowData, ctx'); const contentFunctionInfo = getCellContentFunctionInfo(this.ctx, keySettings, 'value, rowData, ctx'); const columnDefaultVisibility = getColumnDefaultVisibility(keySettings, this.ctx); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/trip-animation/trip-animation.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/trip-animation/trip-animation.component.ts index 7bd6e226f0..d351cabbde 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/trip-animation/trip-animation.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/trip-animation/trip-animation.component.ts @@ -31,7 +31,7 @@ import { defaultTripAnimationSettings, MapProviders, WidgetUnitedTripAnimationSettings -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { WidgetContext } from '@app/modules/home/models/widget-component.models'; import { @@ -39,7 +39,7 @@ import { getRatio, interpolateOnLineSegment, parseWithTranslation -} from '@home/components/widget/lib/maps/common-maps-utils'; +} from '@home/components/widget/lib/maps-legacy/common-maps-utils'; import { FormattedData, WidgetConfig } from '@shared/models/widget.models'; import moment from 'moment'; import { @@ -51,7 +51,7 @@ import { parseTbFunction, safeExecuteTbFunction } from '@core/utils'; -import { MapWidgetInterface } from '@home/components/widget/lib/maps/map-widget.interface'; +import { MapWidgetInterface } from '@home/components/widget/lib/maps-legacy/map-widget.interface'; import { firstValueFrom, from } from 'rxjs'; interface DataMap { @@ -123,7 +123,7 @@ export class TripAnimationComponent implements OnInit, AfterViewInit, OnDestroy } ngAfterViewInit() { - import('@home/components/widget/lib/maps/map-widget2').then( + import('@home/components/widget/lib/maps-legacy/map-widget2').then( (mod) => { this.mapWidget = new mod.MapWidgetController(MapProviders.openstreet, false, this.ctx, this.mapContainer.nativeElement, false, () => { @@ -378,4 +378,4 @@ export class TripAnimationComponent implements OnInit, AfterViewInit, OnDestroy } } -export let TbTripAnimationWidget = TripAnimationComponent; +export const TbTripAnimationWidget = TripAnimationComponent; diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts b/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts index 22e0d725e5..1a397d3a1f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts @@ -40,11 +40,12 @@ import { migrateWidgetTypeToDynamicForms, Widget, widgetActionSources, + WidgetActionType, WidgetControllerDescriptor, WidgetType } from '@shared/models/widget.models'; import { catchError, map, mergeMap, switchMap, tap } from 'rxjs/operators'; -import { isFunction, isUndefined } from '@core/utils'; +import { isDefinedAndNotNull, isFunction, isUndefined } from '@core/utils'; import { TranslateService } from '@ngx-translate/core'; import { DynamicWidgetComponent } from '@home/components/widget/dynamic-widget.component'; import { WidgetComponentsModule } from '@home/components/widget/widget-components.module'; @@ -185,7 +186,7 @@ export class WidgetComponentService { (window as any).TbCanvasDigitalGauge = mod.TbCanvasDigitalGauge; })) ); - widgetModulesTasks.push(from(import('@home/components/widget/lib/maps/map-widget2')).pipe( + widgetModulesTasks.push(from(import('@home/components/widget/lib/maps-legacy/map-widget2')).pipe( tap((mod) => { (window as any).TbMapWidgetV2 = mod.TbMapWidgetV2; })) @@ -633,6 +634,9 @@ export class WidgetComponentService { if (isUndefined(result.typeParameters.overflowVisible)) { result.typeParameters.overflowVisible = false; } + if (isUndefined(result.typeParameters.hideDataTab)) { + result.typeParameters.hideDataTab = false; + } if (isUndefined(result.typeParameters.hideDataSettings)) { result.typeParameters.hideDataSettings = false; } @@ -651,6 +655,13 @@ export class WidgetComponentService { if (isUndefined(result.typeParameters.targetDeviceOptional)) { result.typeParameters.targetDeviceOptional = false; } + if (isDefinedAndNotNull(result.typeParameters.additionalWidgetActionTypes)) { + if (Array.isArray(result.typeParameters.additionalWidgetActionTypes)) { + result.typeParameters.additionalWidgetActionTypes = result.typeParameters.additionalWidgetActionTypes.filter(type => WidgetActionType[type]); + } else { + result.typeParameters.additionalWidgetActionTypes = null; + } + } if (isFunction(widgetTypeInstance.actionSources)) { result.actionSources = widgetTypeInstance.actionSources(); } else { diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index fba18edd4f..cdc829a96f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -39,7 +39,7 @@ import { EdgesOverviewWidgetComponent } from '@home/components/widget/lib/edges- import { JsonInputWidgetComponent } from '@home/components/widget/lib/json-input-widget.component'; import { QrCodeWidgetComponent } from '@home/components/widget/lib/qrcode-widget.component'; import { MarkdownWidgetComponent } from '@home/components/widget/lib/markdown-widget.component'; -import { SelectEntityDialogComponent } from '@home/components/widget/lib/maps/dialogs/select-entity-dialog.component'; +import { SelectEntityDialogComponent } from '@home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component'; import { HomePageWidgetsModule } from '@home/components/widget/lib/home-page/home-page-widgets.module'; import { WIDGET_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens'; import { FlotWidgetComponent } from '@home/components/widget/lib/flot-widget.component'; @@ -89,6 +89,11 @@ import { EllipsisChipListDirective } from '@shared/directives/ellipsis-chip-list import { ScadaSymbolWidgetComponent } from '@home/components/widget/lib/scada/scada-symbol-widget.component'; import { TwoSegmentButtonWidgetComponent } from '@home/components/widget/lib/button/two-segment-button-widget.component'; import { ValueStepperWidgetComponent } from '@home/components/widget/lib/rpc/value-stepper-widget.component'; +import { MapWidgetComponent } from '@home/components/widget/lib/maps/map-widget.component'; +import { + SelectMapEntityPanelComponent +} from '@home/components/widget/lib/maps/panels/select-map-entity-panel.component'; +import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/panels/map-timeline-panel.component'; @NgModule({ declarations: [ @@ -143,7 +148,10 @@ import { ValueStepperWidgetComponent } from '@home/components/widget/lib/rpc/val LabelValueCardWidgetComponent, UnreadNotificationWidgetComponent, NotificationTypeFilterPanelComponent, - ScadaSymbolWidgetComponent + ScadaSymbolWidgetComponent, + SelectMapEntityPanelComponent, + MapTimelinePanelComponent, + MapWidgetComponent ], imports: [ CommonModule, @@ -205,7 +213,8 @@ import { ValueStepperWidgetComponent } from '@home/components/widget/lib/rpc/val LabelValueCardWidgetComponent, UnreadNotificationWidgetComponent, NotificationTypeFilterPanelComponent, - ScadaSymbolWidgetComponent + ScadaSymbolWidgetComponent, + MapWidgetComponent ], providers: [ {provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule}, diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index 9e5a5b7bfc..ae4e35aa9c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html @@ -26,7 +26,7 @@
    -
    +
    @@ -164,6 +164,7 @@ [callbacks]="widgetConfigCallbacks" [widgetType] = "modelValue.widgetType" [actionSources]="modelValue.actionSources" + [additionalWidgetActionTypes]="modelValue.typeParameters.additionalWidgetActionTypes" formControlName="actions">
    @@ -278,6 +279,12 @@
    + + + +
    widget-config.data-settings
    @@ -303,6 +310,7 @@ - +
    + @for (action of widget.customHeaderActions; track action.name; let last = $last) { + + } +
    +
    + + +@switch (action.buttonType) { + @case (widgetHeaderActionButtonType.miniFab) { + + } + @case (widgetHeaderActionButtonType.basic) { + + } + @case (widgetHeaderActionButtonType.raised) { + + } + @case (widgetHeaderActionButtonType.stroked) { + + } + @case (widgetHeaderActionButtonType.flat) { + + } + @default { + + } +} + diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-container.component.scss b/ui-ngx/src/app/modules/home/components/widget/widget-container.component.scss index 0894da25f7..b246bc1662 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-container.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/widget-container.component.scss @@ -86,7 +86,7 @@ div.tb-widget { flex-direction: row; place-content: center flex-start; align-items: center; - z-index: 19; + z-index: 101; margin: 5px 0 0; &-absolute { diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts index 80afff855f..430cce40b1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts @@ -44,8 +44,10 @@ import { GridsterItemComponent } from 'angular-gridster2'; import { UtilsService } from '@core/services/utils.service'; import { from } from 'rxjs'; import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; -import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; import { TbContextMenuEvent } from '@shared/models/jquery-event.models'; +import { WidgetHeaderActionButtonType } from '@shared/models/widget.models'; +import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; +import ITooltipsterGeoHelper = JQueryTooltipster.ITooltipsterGeoHelper; export enum WidgetComponentActionType { MOUSE_DOWN, @@ -124,12 +126,13 @@ export class WidgetContainerComponent extends PageComponent implements OnInit, O widgetComponentAction: EventEmitter = new EventEmitter(); hovered = false; - isReferenceWidget = false; get widgetEditActionsEnabled(): boolean { return (this.isEditActionEnabled || this.isRemoveActionEnabled || this.isExportActionEnabled) && !this.widget?.isFullscreen; } + widgetHeaderActionButtonType = WidgetHeaderActionButtonType; + private cssClass: string; private editWidgetActionsTooltip: ITooltipsterInstance; @@ -204,7 +207,7 @@ export class WidgetContainerComponent extends PageComponent implements OnInit, O } onMouseDown(event: MouseEvent) { - if (event) { + if (event && this.isEdit) { event.stopPropagation(); } this.widgetComponentAction.emit({ @@ -295,6 +298,7 @@ export class WidgetContainerComponent extends PageComponent implements OnInit, O theme: ['tb-widget-edit-actions-tooltip'], interactive: true, trigger: 'custom', + ignoreCloseOnScroll: true, triggerOpen: { mouseenter: true }, @@ -305,6 +309,9 @@ export class WidgetContainerComponent extends PageComponent implements OnInit, O trackOrigin: true, trackerInterval: 25, content: '', + checkOverflowY: (geo: ITooltipsterGeoHelper, bcr: DOMRect) => { + return geo.origin.windowOffset.top < bcr.top || geo.origin.windowOffset.bottom < bcr.bottom; + }, functionPosition: (instance, helper, position) => { const clientRect = helper.origin.getBoundingClientRect(); const container = parent.getBoundingClientRect(); @@ -314,6 +321,7 @@ export class WidgetContainerComponent extends PageComponent implements OnInit, O return position; }, functionReady: (_instance, helper) => { + this.editWidgetActionsTooltip.__scrollHandler({}); const tooltipEl = $(helper.tooltip); tooltipEl.on('mouseenter', () => { this.hovered = true; diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index f4b2cd7e06..404992e186 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -248,6 +248,8 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, this.widgetContext.isPreview = this.isPreview; this.widgetContext.isMobile = this.isMobile; this.widgetContext.toastTargetId = this.toastTargetId; + this.widgetContext.renderer = this.renderer; + this.widgetContext.widgetContentContainer = this.widgetContentContainer; this.widgetContext.subscriptionApi = { createSubscription: this.createSubscription.bind(this), @@ -271,7 +273,8 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, click: this.click.bind(this), getActiveEntityInfo: this.getActiveEntityInfo.bind(this), openDashboardStateInSeparateDialog: this.openDashboardStateInSeparateDialog.bind(this), - openDashboardStateInPopover: this.openDashboardStateInPopover.bind(this) + openDashboardStateInPopover: this.openDashboardStateInPopover.bind(this), + placeMapItem: () => {} }; this.widgetContext.customHeaderActions = []; @@ -295,7 +298,13 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, const headerAction: WidgetHeaderAction = { name: descriptor.name, displayName: descriptor.displayName, + buttonType: descriptor.buttonType, + showIcon: descriptor.showIcon, icon: descriptor.icon, + buttonColor: descriptor.buttonColor, + buttonFillColor: descriptor.buttonFillColor, + buttonBorderColor: descriptor.buttonBorderColor, + customButtonStyle: descriptor.customButtonStyle, descriptor, useShowWidgetHeaderActionFunction, showWidgetHeaderActionFunction, @@ -1147,45 +1156,16 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, ) } break; - case WidgetActionType.customPretty: - const customPrettyFunction = descriptor.customFunction; - const customHtml = descriptor.customHtml; - const customCss = descriptor.customCss; - const customResources = descriptor.customResources; - const actionNamespace = `custom-action-pretty-${guid()}`; - let htmlTemplate = ''; - if (isDefined(customHtml) && customHtml.length > 0) { - htmlTemplate = customHtml; - } - this.loadCustomActionResources(actionNamespace, customCss, customResources, descriptor).subscribe({ - next: () => { - if (isNotEmptyTbFunction(customPrettyFunction)) { - compileTbFunction(this.http, customPrettyFunction, '$event', 'widgetContext', 'entityId', - 'entityName', 'htmlTemplate', 'additionalParams', 'entityLabel').subscribe( - { - next: (compiled) => { - try { - if (!additionalParams) { - additionalParams = {}; - } - this.widgetContext.customDialog.setAdditionalImports(descriptor.customImports); - compiled.execute($event, this.widgetContext, entityId, entityName, htmlTemplate, additionalParams, entityLabel); - } catch (e) { - console.error(e); - } - }, - error: (err) => { - console.error(err); - } - } - ) - } - }, - error: (errorMessages: string[]) => { - this.processResourcesLoadErrors(errorMessages); - } + case WidgetActionType.placeMapItem: + this.widgetContext.actionsApi.placeMapItem({ + action: descriptor, + afterPlaceItemCallback: this.executeCustomPrettyAction.bind(this), + additionalParams: additionalParams }); break; + case WidgetActionType.customPretty: + this.executeCustomPrettyAction($event, descriptor, entityId, entityName, additionalParams, entityLabel); + break; case WidgetActionType.mobileAction: const mobileAction = descriptor.mobileAction; this.handleMobileAction($event, mobileAction, entityId, entityName, additionalParams, entityLabel); @@ -1203,6 +1183,7 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, case WidgetMobileActionType.scanQrCode: case WidgetMobileActionType.getLocation: case WidgetMobileActionType.takeScreenshot: + case WidgetMobileActionType.deviceProvision: argsObservable = of([]); break; case WidgetMobileActionType.mapDirection: @@ -1292,6 +1273,26 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, ); } break; + case WidgetMobileActionType.deviceProvision: + const deviceName = actionResult.deviceName; + if (isNotEmptyTbFunction(mobileAction.handleProvisionSuccessFunction)) { + compileTbFunction(this.http, mobileAction.handleProvisionSuccessFunction, 'deviceName', '$event', 'widgetContext', 'entityId', + 'entityName', 'additionalParams', 'entityLabel').subscribe( + { + next: (compiled) => { + try { + compiled.execute(deviceName, $event, this.widgetContext, entityId, entityName, additionalParams, entityLabel); + } catch (e) { + console.error(e); + } + }, + error: (err) => { + console.error(err); + } + } + ); + } + break; case WidgetMobileActionType.scanQrCode: const code = actionResult.code; const format = actionResult.format; @@ -1556,6 +1557,45 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, this.handleWidgetAction($event, action, entityId, entityName, null, entityLabel); } + private executeCustomPrettyAction($event: Event, descriptor: WidgetAction, entityId?: EntityId, + entityName?: string, additionalParams?: any, entityLabel?: string) { + const customPrettyFunction = descriptor.customFunction; + const customHtml = descriptor.customHtml; + const customCss = descriptor.customCss; + const customResources = descriptor.customResources; + const actionNamespace = `custom-action-pretty-${guid()}`; + let htmlTemplate = ''; + if (isDefined(customHtml) && customHtml.length > 0) { + htmlTemplate = customHtml; + } + this.loadCustomActionResources(actionNamespace, customCss, customResources, descriptor).subscribe({ + next: () => { + if (isNotEmptyTbFunction(customPrettyFunction)) { + compileTbFunction(this.http, customPrettyFunction, '$event', 'widgetContext', 'entityId', + 'entityName', 'htmlTemplate', 'additionalParams', 'entityLabel').subscribe({ + next: (compiled) => { + try { + if (!additionalParams) { + additionalParams = {}; + } + this.widgetContext.customDialog.setAdditionalImports(descriptor.customImports); + compiled.execute($event, this.widgetContext, entityId, entityName, htmlTemplate, additionalParams, entityLabel); + } catch (e) { + console.error(e); + } + }, + error: (err) => { + console.error(err); + } + }); + } + }, + error: (errorMessages: string[]) => { + this.processResourcesLoadErrors(errorMessages); + } + }); + } + private loadCustomActionResources(actionNamespace: string, customCss: string, customResources: Array, actionDescriptor: WidgetAction): Observable { const resourceTasks: Observable[] = []; diff --git a/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts b/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts index d2435c8641..5117aa6430 100644 --- a/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts +++ b/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts @@ -121,7 +121,11 @@ export class EntityLinkTableColumn> extends BaseEntity public width: string = '0px', public cellContentFunction: CellContentFunction = (entity, property) => entity[property] ? entity[property] : '', public entityURL: (entity) => string, - public sortable: boolean = true) { + public sortable: boolean = true, + public cellStyleFunction: CellStyleFunction = () => ({}), + public headerCellStyleFunction: HeaderCellStyleFunction = () => ({}), + public cellTooltipFunction: CellTooltipFunction = () => undefined, + public actionCell: CellActionDescriptor = null) { super('link', key, title, width, sortable); } } diff --git a/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts b/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts index 3accf6bfb9..6317f267ca 100644 --- a/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts @@ -20,7 +20,7 @@ import { SafeHtml } from '@angular/platform-browser'; import { PageLink } from '@shared/models/page/page-link'; import { Timewindow } from '@shared/models/time/time.models'; import { EntitiesDataSource } from '@home/models/datasource/entity-datasource'; -import { ElementRef, EventEmitter } from '@angular/core'; +import { ElementRef, EventEmitter, Renderer2, ViewContainerRef } from '@angular/core'; import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; import { MatPaginator } from '@angular/material/paginator'; import { MatSort } from '@angular/material/sort'; @@ -64,6 +64,7 @@ export interface IEntitiesTableComponent { paginator: MatPaginator; sort: MatSort; route: ActivatedRoute; + viewContainerRef: ViewContainerRef; addEnabled(): boolean; clearSelection(): void; diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index b55ec2fd54..8a8724c116 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -26,6 +26,7 @@ import { WidgetActionSource, WidgetConfig, WidgetControllerDescriptor, + WidgetHeaderActionButtonType, WidgetType, widgetType, WidgetTypeDescriptor, @@ -46,7 +47,15 @@ import { WidgetActionsApi, WidgetSubscriptionApi } from '@core/api/widget-api.models'; -import { ChangeDetectorRef, InjectionToken, Injector, NgZone, TemplateRef, Type } from '@angular/core'; +import { + ChangeDetectorRef, + InjectionToken, + Injector, + NgZone, Renderer2, + TemplateRef, + Type, + ViewContainerRef +} from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { RafService } from '@core/services/raf.service'; import { WidgetTypeId } from '@shared/models/id/widget-type-id'; @@ -102,7 +111,7 @@ import { MillisecondsToTimeStringPipe } from '@shared/pipe/milliseconds-to-time- import { SharedTelemetrySubscriber, TelemetrySubscriber } from '@shared/models/telemetry/telemetry.models'; import { UserId } from '@shared/models/id/user-id'; import { UserSettingsService } from '@core/http/user-settings.service'; -import { DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeySettingsFunction } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { UtilsService } from '@core/services/utils.service'; import { CompiledTbFunction } from '@shared/models/js-function.models'; import { FormProperty } from '@shared/models/dynamic-form.models'; @@ -118,6 +127,12 @@ export type ShowWidgetHeaderActionFunction = (ctx: WidgetContext, data: Formatte export interface WidgetHeaderAction extends IWidgetAction { displayName: string; descriptor: WidgetActionDescriptor; + buttonType?: WidgetHeaderActionButtonType; + showIcon?:boolean; + buttonColor?: string; + buttonFillColor?: string; + buttonBorderColor?: string; + customButtonStyle?: string; useShowWidgetHeaderActionFunction: boolean; showWidgetHeaderActionFunction: CompiledTbFunction; } @@ -215,6 +230,8 @@ export class WidgetContext { http: HttpClient; sanitizer: DomSanitizer; router: Router; + renderer: Renderer2; + widgetContentContainer: ViewContainerRef; private changeDetectorValue: ChangeDetectorRef; private containerChangeDetectorValue: ChangeDetectorRef; diff --git a/ui-ngx/src/app/modules/home/pages/admin/settings-card.scss b/ui-ngx/src/app/modules/home/pages/admin/settings-card.scss index 50cd35d9da..7c707dee6d 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/settings-card.scss +++ b/ui-ngx/src/app/modules/home/pages/admin/settings-card.scss @@ -19,6 +19,10 @@ .mat-mdc-card.settings-card { margin: 8px; + mat-card-header.mat-mdc-card-header { + align-items: center; + } + .fields-group { padding: 0 16px 8px; margin: 10px 0; diff --git a/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.scss b/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.scss index 137da30c50..0897a72ec1 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.scss +++ b/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.scss @@ -15,7 +15,6 @@ */ :host { .mat-mdc-card-header { - align-items: center; min-height: 64px; } diff --git a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.html b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.html index ea93ea4c6b..e4431abfed 100644 --- a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.html @@ -15,6 +15,10 @@ limitations under the License. --> + + + diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html index 0cd3e759b9..767d11eb23 100644 --- a/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html @@ -32,6 +32,10 @@ [entityName]="entity.name"> + + + diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html index c45491a608..46aa08ff9f 100644 --- a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html @@ -40,6 +40,10 @@
    + + + + + + diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.html index 16840f3394..f1ac4d1213 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.html @@ -43,6 +43,22 @@ mobile.page-name + + warning + + + warning + diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.ts index 04583c1b6e..5e01758939 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.ts @@ -17,7 +17,7 @@ import { Component, DestroyRef, EventEmitter, inject, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; import { DefaultMobilePage, defaultMobilePageMap, hideDefaultMenuItems } from '@shared/models/mobile-app.models'; import { TbPopoverComponent } from '@shared/components/popover.component'; -import { FormBuilder } from '@angular/forms'; +import { FormBuilder, Validators } from '@angular/forms'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ @@ -43,7 +43,7 @@ export class DefaultMobilePagePanelComponent implements OnInit { mobilePageFormGroup = this.fb.group({ visible: [true], icon: [''], - label: [''], + label: ['', [Validators.pattern(/\S/), Validators.maxLength(255)]], }); isCleanupEnabled = false; diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html index 653a9ebeab..149be8dd15 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html @@ -50,6 +50,14 @@ class="tb-error"> warning + + warning + diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.ts index 789d96b5cb..33be223815 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.ts @@ -99,7 +99,7 @@ export class MobilePageItemRowComponent implements ControlValueAccessor, OnInit, mobilePageRowForm = this.fb.group({ visible: [true, []], icon: ['', []], - label: ['', [Validators.pattern(/\S/)]], + label: ['', [Validators.pattern(/\S/), Validators.maxLength(255)]], type: [MobilePageType.DEFAULT] }); diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts index d927f64fdd..2ac9b806e6 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts @@ -369,7 +369,7 @@ export class RuleChainPageComponent extends PageComponent private initHotKeys(): void { if (!this.hotKeys.length) { this.hotKeys.push( - new Hotkey('ctrl+a', (event: KeyboardEvent) => { + new Hotkey(['ctrl+a', 'meta+a'], (event: KeyboardEvent) => { if (this.enableHotKeys) { event.preventDefault(); this.ruleChainCanvas.modelService.selectAll(); @@ -380,7 +380,7 @@ export class RuleChainPageComponent extends PageComponent this.translate.instant('rulenode.select-all-objects')) ); this.hotKeys.push( - new Hotkey('ctrl+c', (event: KeyboardEvent) => { + new Hotkey(['ctrl+c', 'meta+c'], (event: KeyboardEvent) => { if (this.enableHotKeys) { event.preventDefault(); this.copyRuleNodes(); @@ -391,7 +391,7 @@ export class RuleChainPageComponent extends PageComponent this.translate.instant('rulenode.copy-selected')) ); this.hotKeys.push( - new Hotkey('ctrl+v', (event: KeyboardEvent) => { + new Hotkey(['ctrl+v', 'meta+v'], (event: KeyboardEvent) => { if (this.enableHotKeys) { event.preventDefault(); if (this.itembuffer.hasRuleNodes()) { @@ -416,7 +416,7 @@ export class RuleChainPageComponent extends PageComponent this.translate.instant('rulenode.deselect-all-objects')) ); this.hotKeys.push( - new Hotkey('ctrl+s', (event: KeyboardEvent) => { + new Hotkey(['ctrl+s', 'meta+s'], (event: KeyboardEvent) => { if (this.enableHotKeys) { event.preventDefault(); this.saveRuleChain(); @@ -427,7 +427,7 @@ export class RuleChainPageComponent extends PageComponent this.translate.instant('action.apply')) ); this.hotKeys.push( - new Hotkey('ctrl+z', (event: KeyboardEvent) => { + new Hotkey(['ctrl+z', 'meta+z'], (event: KeyboardEvent) => { if (this.enableHotKeys) { event.preventDefault(); this.revertRuleChain(); @@ -449,7 +449,7 @@ export class RuleChainPageComponent extends PageComponent this.translate.instant('rulenode.delete-selected-objects')) ); this.hotKeys.push( - new Hotkey('ctrl+r', (event: KeyboardEvent) => { + new Hotkey(['ctrl+r', 'meta+r'], (event: KeyboardEvent) => { if (this.enableHotKeys && this.canCreateNestedRuleChain()) { event.preventDefault(); this.createNestedRuleChain(); diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.models.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.models.ts index f6d37d3390..13b55d4f67 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.models.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.models.ts @@ -31,7 +31,12 @@ import { } from '@home/components/widget/lib/scada/scada-symbol.models'; import { TbEditorCompletion, TbEditorCompletions } from '@shared/models/ace/completion.models'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; -import { AceHighlightRule, AceHighlightRules } from '@shared/models/ace/ace.models'; +import { + AceHighlightRule, + AceHighlightRules, + dotOperatorHighlightRule, + endGroupHighlightRule +} from '@shared/models/ace/ace.models'; import { HelpLinks, ValueType } from '@shared/models/constants'; import { formPropertyCompletions } from '@shared/models/dynamic-form.models'; import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; @@ -921,17 +926,6 @@ export class ScadaSymbolElement { const identifierRe = /[a-zA-Z$_\u00a1-\uffff][a-zA-Z\d$_\u00a1-\uffff]*/; -const dotOperatorHighlightRule: AceHighlightRule = { - token: 'punctuation.operator', - regex: /[.](?![.])/, -}; - -const endGroupHighlightRule: AceHighlightRule = { - regex: '', - token: 'empty', - next: 'no_regex' -}; - const scadaSymbolCtxObjectHighlightRule: AceHighlightRule = { token: 'tb.scada-symbol-ctx', regex: /\bctx\b/, diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html index d1e8107108..fec242ecc6 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html @@ -233,8 +233,8 @@ - {{descriptionInput.value?.length || 0}}/255 + rows="2" maxlength="1024"> + {{descriptionInput.value?.length || 0}}/1024 -
    +

    {{ title }}

    @@ -25,30 +25,26 @@ close
    - - -
    -
    -
    - - -
    +
    +
    + +
    diff --git a/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts index d409e4a80c..d6f733ade7 100644 --- a/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts @@ -30,6 +30,7 @@ export interface JsonObjectEditDialogData { title?: string; saveLabel?: string; cancelLabel?: string; + fillHeight?: boolean; } @Component({ diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html index ca1fbe412a..92a316efe9 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html @@ -15,11 +15,18 @@ limitations under the License. --> - - {{ label | translate }} + {{ label | translate }} {{ 'entity.create-new' | translate }} + + warning + @@ -59,7 +75,7 @@
    - + {{ requiredErrorText | translate }} diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts index 85e1a9efcc..ac8054283a 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts @@ -14,7 +14,16 @@ /// limitations under the License. /// -import { Component, ElementRef, EventEmitter, forwardRef, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { + Component, + ElementRef, + EventEmitter, + forwardRef, + Input, + OnInit, + Output, + ViewChild +} from '@angular/core'; import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { firstValueFrom, merge, Observable, of, Subject } from 'rxjs'; @@ -105,6 +114,9 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit @Input() requiredText: string; + @Input() + placeholder: string; + @Input() @coerceBoolean() useFullEntityId: boolean; @@ -112,6 +124,10 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit @Input() appearance: MatFormFieldAppearance = 'fill'; + @Input() + @coerceBoolean() + inlineField: boolean; + @Input() @coerceBoolean() required: boolean; @@ -375,7 +391,7 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit private updateView(value: string | EntityId | null, entity: BaseData | null) { if (!isEqual(this.modelValue, value)) { this.modelValue = value; - this.entityURL = !entity ? '' : getEntityDetailsPageURL(entity.id.id, entity.id.entityType as EntityType); + this.entityURL = (typeof entity === 'string' || !entity) ? '' : getEntityDetailsPageURL(entity.id.id, entity.id.entityType as EntityType); this.propagateChange(this.modelValue); this.entityChanged.emit(entity); } diff --git a/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html new file mode 100644 index 0000000000..839a2e00cd --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html @@ -0,0 +1,51 @@ + + + + @if (keyControl.value) { + + } @else if (keyControl.hasError('required') && keyControl.touched) { + + warning + + } + + @for (key of filteredKeys$ | async; track key) { + + } @empty { + @if (!this.keyControl.value) { + {{ 'entity.no-keys-found' | translate }} + } + } + + diff --git a/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts new file mode 100644 index 0000000000..84d723d114 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts @@ -0,0 +1,145 @@ +/// +/// 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, effect, ElementRef, forwardRef, input, OnChanges, SimpleChanges, ViewChild, } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { map, startWith, switchMap } from 'rxjs/operators'; +import { combineLatest, of, Subject } from 'rxjs'; +import { EntityService } from '@core/http/entity.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { EntitiesKeysByQuery } from '@shared/models/entity.models'; +import { EntityFilter } from '@shared/models/query/query.models'; +import { isEqual } from '@core/utils'; + +@Component({ + selector: 'tb-entity-key-autocomplete', + templateUrl: './entity-key-autocomplete.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityKeyAutocompleteComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => EntityKeyAutocompleteComponent), + multi: true + } + ], +}) +export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Validator, OnChanges { + + @ViewChild('keyInput', {static: true}) keyInput: ElementRef; + + entityFilter = input.required(); + dataKeyType = input.required(); + keyScopeType = input(); + + keyControl = this.fb.control('', [Validators.required]); + searchText = ''; + keyInputSubject = new Subject(); + + private propagateChange: (value: string) => void; + private cachedResult: EntitiesKeysByQuery; + + keys$ = this.keyInputSubject.asObservable() + .pipe( + switchMap(() => { + return this.cachedResult ? of(this.cachedResult) : this.entityService.findEntityKeysByQuery({ + pageLink: { page: 0, pageSize: 100 }, + entityFilter: this.entityFilter(), + }, this.dataKeyType() === DataKeyType.attribute, this.dataKeyType() === DataKeyType.timeseries, this.keyScopeType()); + }), + map(result => { + this.cachedResult = result; + switch (this.dataKeyType()) { + case DataKeyType.attribute: + return result.attribute; + case DataKeyType.timeseries: + return result.timeseries; + default: + return []; + } + }), + ); + + filteredKeys$ = combineLatest([this.keys$, this.keyControl.valueChanges.pipe(startWith(''))]) + .pipe( + map(([keys, searchText = '']) => { + this.searchText = searchText; + return searchText ? keys.filter(item => item.toLowerCase().includes(searchText.toLowerCase())) : keys; + }) + ); + + constructor( + private fb: FormBuilder, + private entityService: EntityService, + ) { + this.keyControl.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(value => this.propagateChange(value)); + effect(() => { + if (this.keyScopeType() || this.entityFilter() && this.dataKeyType()) { + this.cachedResult = null; + this.searchText = ''; + } + }); + } + + ngOnChanges(changes: SimpleChanges): void { + const filterChanged = changes.entityFilter?.previousValue && + !isEqual(changes.entityFilter.currentValue, changes.entityFilter.previousValue); + const keyScopeChanged = changes.keyScopeType?.previousValue && + changes.keyScopeType.currentValue !== changes.keyScopeType.previousValue; + const keyTypeChanged = changes.dataKeyType?.previousValue && + changes.dataKeyType.currentValue !== changes.dataKeyType.previousValue; + + if (filterChanged || keyScopeChanged || keyTypeChanged) { + this.keyControl.setValue('', {emitEvent: false}); + } + } + + clear(): void { + this.keyControl.patchValue('', {emitEvent: true}); + setTimeout(() => { + this.keyInput.nativeElement.blur(); + this.keyInput.nativeElement.focus(); + }, 0); + } + + registerOnChange(onChange: (value: string) => void): void { + this.propagateChange = onChange; + } + + registerOnTouched(_): void {} + + validate(): ValidationErrors | null { + return this.keyControl.valid ? null : { keyControl: false }; + } + + writeValue(value: string): void { + this.keyControl.patchValue(value, {emitEvent: false}); + } +} diff --git a/ui-ngx/src/app/shared/components/js-func.component.html b/ui-ngx/src/app/shared/components/js-func.component.html index 3155669f29..07f6dfe1b4 100644 --- a/ui-ngx/src/app/shared/components/js-func.component.html +++ b/ui-ngx/src/app/shared/components/js-func.component.html @@ -41,7 +41,7 @@ {{'js-func.tidy' | translate }} -
    +
    ; + @Input() helpPopupStyle: Record = {}; + @Input() @coerceBoolean() disableUndefinedCheck = false; @@ -178,28 +183,11 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor, } ngOnInit(): void { - if (this.functionTitle || this.label) { - this.hideBrackets = true; - } if (!this.resultType || this.resultType.length === 0) { this.resultType = 'nocheck'; } - if (this.functionArgs) { - this.functionArgs.forEach((functionArg) => { - if (this.functionArgsString.length > 0) { - this.functionArgsString += ', '; - } - this.functionArgsString += functionArg; - }); - } - if (this.functionTitle) { - this.functionLabel = `${this.functionTitle}: f(${this.functionArgsString})`; - } else if (this.label) { - this.functionLabel = this.label; - } else { - this.functionLabel = - `function ${this.functionName ? this.functionName : ''}(${this.functionArgsString})${this.hideBrackets ? '' : ' {'}`; - } + this.updateFunctionArgsString() + this.updateFunctionLabel(); const editorElement = this.javascriptEditorElmRef.nativeElement; let editorOptions: Partial = { mode: 'ace/mode/javascript', @@ -251,21 +239,7 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor, } }); } - // @ts-ignore - if (!!this.highlightRules && !!this.jsEditor.session.$mode) { - // @ts-ignore - const newMode = new this.jsEditor.session.$mode.constructor(); - newMode.$highlightRules = new newMode.HighlightRules(); - for(const group in this.highlightRules) { - if(!!newMode.$highlightRules.$rules[group]) { - newMode.$highlightRules.$rules[group].unshift(...this.highlightRules[group]); - } else { - newMode.$highlightRules.$rules[group] = this.highlightRules[group]; - } - } - // @ts-ignore - this.jsEditor.session.$onChangeMode(newMode); - } + this.updateHighlightRules(); this.updateJsWorkerGlobals(); this.initialCompleters = this.jsEditor.completers || []; this.updateCompleters(); @@ -277,6 +251,16 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor, ); } + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const { firstChange, currentValue, previousValue } = changes[propName]; + const isChanged = isObject(currentValue) ? !isEqual(currentValue, previousValue) : currentValue !== previousValue; + if (!firstChange && isChanged) { + this.updateByChangesPropName(propName); + } + } + } + ngOnDestroy(): void { if (this.editorResize$) { this.editorResize$.disconnect(); @@ -329,6 +313,32 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor, ); } + private updateFunctionArgsString(): void { + this.functionArgsString = ''; + if (this.functionArgs) { + this.functionArgsString = this.functionArgs.join(', '); + } + } + + private updateFunctionLabel(): void { + if (this.functionTitle || this.label) { + this.hideBrackets = true; + } + if (this.functionTitle) { + this.functionLabel = `${this.functionTitle}: f(${this.functionArgsString})`; + } else if (this.label) { + this.functionLabel = this.label; + } else { + this.functionLabel = + `function ${this.functionName ? this.functionName : ''}(${this.functionArgsString})${this.hideBrackets ? '' : ' {'}`; + } + this.cd.markForCheck(); + } + + private updatedScriptLanguage() { + this.jsEditor.session.setMode(`ace/mode/${ScriptLanguage.TBEL === this.scriptLanguage ? 'tbel' : 'javascript'}`); + } + validateOnSubmit(): Observable { if (!this.disabled) { this.cleanupJsErrors(); @@ -539,6 +549,64 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor, } } + private updateByChangesPropName(propName: string): void { + switch (propName) { + case 'functionArgs': + this.updateFunctionArgsString() + this.updateFunctionLabel(); + this.updateJsWorkerGlobals(); + break; + case 'label': + case 'functionTitle': + case 'functionName': + this.updateFunctionLabel(); + break; + case 'scriptLanguage': + this.updatedScriptLanguage(); + this.updateHighlightRules(); + this.updateCompleters(); + this.updateJsWorkerGlobals(); + break; + case 'disableUndefinedCheck': + case 'globalVariables': + this.updateJsWorkerGlobals(); + break; + case 'editorCompleter': + this.updateCompleters(); + break; + case 'highlightRules': + this.updateHighlightRules(); + break; + } + } + + private updateHighlightRules(): void { + // @ts-ignore + if (!!this.jsEditor.session.$mode) { + // @ts-ignore + const newMode = new this.jsEditor.session.$mode.constructor(); + newMode.$highlightRules = new newMode.HighlightRules(); + if (!!this.highlightRules) { + for(const group in this.highlightRules) { + if(!!newMode.$highlightRules.$rules[group]) { + newMode.$highlightRules.$rules[group].unshift(...this.highlightRules[group]); + } else { + newMode.$highlightRules.$rules[group] = this.highlightRules[group]; + } + } + } + if (this.scriptLanguage === ScriptLanguage.TBEL) { + newMode.$highlightRules.$rules.start = [...tbelUtilsFuncHighlightRules, ...newMode.$highlightRules.$rules.start]; + } + const identifierRule = newMode.$highlightRules.$rules.no_regex.find(rule => rule.token?.includes('identifier')); + if (identifierRule && identifierRule.next === 'no_regex') { + identifierRule.next = 'start'; + } + // @ts-ignore + this.jsEditor.session.$onChangeMode(newMode); + } + } + private updateJsWorkerGlobals() { // @ts-ignore if (!!this.jsEditor.session.$worker) { @@ -584,6 +652,9 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor, if (modulesCompleter) { completers.push(modulesCompleter); } + if (this.scriptLanguage === ScriptLanguage.TBEL) { + completers.push(tbelUtilsAutocompletes); + } completers.push(...this.initialCompleters); this.jsEditor.completers = completers; }); diff --git a/ui-ngx/src/app/shared/components/material-icons.component.html b/ui-ngx/src/app/shared/components/material-icons.component.html index 1df91fc9be..d4d9fe93a6 100644 --- a/ui-ngx/src/app/shared/components/material-icons.component.html +++ b/ui-ngx/src/app/shared/components/material-icons.component.html @@ -16,7 +16,7 @@ -->
    -
    icon.icons
    +
    icon.icons
    search diff --git a/ui-ngx/src/app/shared/components/material-icons.component.ts b/ui-ngx/src/app/shared/components/material-icons.component.ts index 22360affa7..ece1a99108 100644 --- a/ui-ngx/src/app/shared/components/material-icons.component.ts +++ b/ui-ngx/src/app/shared/components/material-icons.component.ts @@ -58,7 +58,11 @@ export class MaterialIconsComponent extends PageComponent implements OnInit { iconClearButton = false; @Input() - popover: TbPopoverComponent; + @coerceBoolean() + showTitle = true; + + @Input() + popover: TbPopoverComponent; @Output() iconSelected = new EventEmitter(); diff --git a/ui-ngx/src/app/shared/components/nav-tree.component.ts b/ui-ngx/src/app/shared/components/nav-tree.component.ts index 789b352a92..5b698a37d3 100644 --- a/ui-ngx/src/app/shared/components/nav-tree.component.ts +++ b/ui-ngx/src/app/shared/components/nav-tree.component.ts @@ -149,9 +149,9 @@ export class NavTreeComponent implements OnInit { this.treeElement = $('.tb-nav-tree-container', this.elementRef.nativeElement).jstree(config); - this.treeElement.on('select_node.jstree', (e: any, data) => { - const node: NavTreeNode = data.instance.get_selected(true)[0]; - if (this.onNodeSelected) { + this.treeElement.on('changed.jstree', (e: any, data) => { + if (this.onNodeSelected && data.action !== 'ready') { + const node: NavTreeNode = data.instance.get_selected(true)[0]; this.ngZone.run(() => this.onNodeSelected(node, e as Event)); } }); diff --git a/ui-ngx/src/app/shared/components/string-autocomplete.component.html b/ui-ngx/src/app/shared/components/string-autocomplete.component.html index 459e41fdd8..8a9a1a38d0 100644 --- a/ui-ngx/src/app/shared/components/string-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/string-autocomplete.component.html @@ -24,7 +24,7 @@ [matAutocomplete]="optionsAutocomplete">
    diff --git a/ui-ngx/src/app/shared/components/value-input.component.ts b/ui-ngx/src/app/shared/components/value-input.component.ts index 312e12a2a7..07bdaaa742 100644 --- a/ui-ngx/src/app/shared/components/value-input.component.ts +++ b/ui-ngx/src/app/shared/components/value-input.component.ts @@ -81,6 +81,14 @@ export class ValueInputComponent implements OnInit, OnDestroy, OnChanges, Contro @coerceBoolean() shortBooleanField = false; + @Input() + @coerceBoolean() + required = true; + + @Input() + @coerceBoolean() + hideJsonEdit = false; + @Input() layout: ValueInputLayout | Layout = 'row'; diff --git a/ui-ngx/src/app/shared/import-export/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts index 5212c15fd4..2ad60f0139 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.service.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts @@ -88,6 +88,8 @@ import { ExportResourceDialogDialogResult } from '@shared/import-export/export-resource-dialog.component'; import { FormProperty, propertyValid } from '@shared/models/dynamic-form.models'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { CalculatedField } from '@shared/models/calculated-field.models'; export type editMissingAliasesFunction = (widgets: Array, isSingleWidget: boolean, customTitle: string, missingEntityAliases: EntityAliases) => Observable; @@ -116,6 +118,7 @@ export class ImportExportService { private imageService: ImageService, private utils: UtilsService, private itembuffer: ItemBufferService, + private calculatedFieldsService: CalculatedFieldsService, private dialog: MatDialog) { } @@ -171,6 +174,25 @@ export class ImportExportService { ); } + public exportCalculatedField(calculatedFieldId: string): void { + this.calculatedFieldsService.getCalculatedFieldById(calculatedFieldId).subscribe({ + next: (calculatedField) => { + let name = calculatedField.name; + name = name.toLowerCase().replace(/\W/g, '_'); + this.exportToPc(this.prepareCalculatedFieldExport(calculatedField), name); + }, + error: (e) => { + this.handleExportError(e, 'calculated-fields.export-failed-error'); + } + }); + } + + public openCalculatedFieldImportDialog(): Observable { + return this.openImportDialog('calculated-fields.import', 'calculated-fields.file').pipe( + catchError(() => of(null)), + ); + } + public exportDashboard(dashboardId: string) { this.getIncludeResourcesPreference('includeResourcesInExportDashboard').subscribe(includeResources => { this.openExportDialog('dashboard.export', 'dashboard.export-prompt', includeResources).subscribe(result => { @@ -307,23 +329,23 @@ export class ImportExportService { } } return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget, - aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize); + aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize, widgetItem.widgetExportInfo); } )); } else { return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget, - aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize); + aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize, widgetItem.widgetExportInfo); } } ) ); } else { return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget, - aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize); + aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize, widgetItem.widgetExportInfo); } } else { return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget, - aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize); + aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize, widgetItem.widgetExportInfo); } } }), @@ -1033,11 +1055,11 @@ export class ImportExportService { filtersInfo: FiltersInfo, onAliasesUpdateFunction: () => void, onFiltersUpdateFunction: () => void, - originalColumns: number, originalSize: WidgetSize): Observable { + originalColumns: number, originalSize: WidgetSize, widgetExportInfo: any): Observable { return targetLayoutFunction().pipe( mergeMap((targetLayout) => this.itembuffer.addWidgetToDashboard(dashboard, targetState, targetLayout, widget, aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, - originalColumns, originalSize, -1, -1).pipe( + originalColumns, originalSize, -1, -1, 'default', widgetExportInfo).pipe( map(() => ({widget, layoutId: targetLayout} as ImportWidgetResult)) ) )); @@ -1209,6 +1231,11 @@ export class ImportExportService { return profile; } + private prepareCalculatedFieldExport(calculatedField: CalculatedField): CalculatedField { + delete calculatedField.entityId; + return this.prepareExport(calculatedField); + } + private prepareExport(data: any): any { const exportedData = deepClone(data); if (isDefined(exportedData.id)) { diff --git a/ui-ngx/src/app/shared/legacy/flex-layout.models.ts b/ui-ngx/src/app/shared/legacy/flex-layout.models.ts deleted file mode 100644 index 4038fee802..0000000000 --- a/ui-ngx/src/app/shared/legacy/flex-layout.models.ts +++ /dev/null @@ -1,41 +0,0 @@ -/// -/// 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 { Observable } from 'rxjs/internal/Observable'; -import { from, of } from 'rxjs'; -import { map, tap } from 'rxjs/operators'; -import { Type } from '@angular/core'; - -let flexLayoutModule: any; - -export function getFlexLayout(): Observable { - if (flexLayoutModule) { - return of(flexLayoutModule); - } else { - return from(import('@angular/flex-layout')).pipe( - tap((module) => { - module.DEFAULT_CONFIG.addFlexToParent = false; - flexLayoutModule = module; - }) - ); - } -} - -export function getFlexLayoutModule(): Observable> { - return getFlexLayout().pipe( - map(module => module.FlexLayoutModule) - ); -} diff --git a/ui-ngx/src/app/shared/models/ace/ace.models.ts b/ui-ngx/src/app/shared/models/ace/ace.models.ts index f6af2d6a52..49cf670989 100644 --- a/ui-ngx/src/app/shared/models/ace/ace.models.ts +++ b/ui-ngx/src/app/shared/models/ace/ace.models.ts @@ -365,5 +365,15 @@ export interface AceHighlightRule { next?: string; } +export const dotOperatorHighlightRule: AceHighlightRule = { + token: 'punctuation.operator', + regex: /[.](?![.])/, +}; + +export const endGroupHighlightRule: AceHighlightRule = { + regex: '', + token: 'empty', + next: 'no_regex' +}; diff --git a/ui-ngx/src/app/shared/models/ace/tbel-utils.models.ts b/ui-ngx/src/app/shared/models/ace/tbel-utils.models.ts new file mode 100644 index 0000000000..f29ae083be --- /dev/null +++ b/ui-ngx/src/app/shared/models/ace/tbel-utils.models.ts @@ -0,0 +1,1259 @@ +/// +/// 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 { AceHighlightRule } from '@shared/models/ace/ace.models'; +import { TbEditorCompleter, TbEditorCompletions } from '@shared/models/ace/completion.models'; + +const tbelEditorCompletions:TbEditorCompletions = { + btoa: { + meta: 'function', + description: 'Encodes a string to Base64.', + args: [ + { + name: 'str', + description: 'The string to encode', + type: 'string' + } + ], + return: { + description: 'The Base64 encoded string', + type: 'string' + } + }, + atob: { + meta: 'function', + description: 'Decodes a Base64 encoded string.', + args: [ + { + name: 'str', + description: 'The Base64 encoded string to decode', + type: 'string' + } + ], + return: { + description: 'The decoded string', + type: 'string' + } + }, + bytesToString: { + meta: 'function', + description: 'Converts a list of bytes to a string, optionally specifying the charset.', + args: [ + { + name: 'data', + description: 'The list of bytes to convert', + type: 'list' + }, + { + name: 'charsetName', + description: 'The charset to use for conversion (e.g., "UTF-8")', + type: 'string', + optional: true + } + ], + return: { + description: 'The string representation of the bytes', + type: 'string' + } + }, + decodeToString: { + meta: 'function', + description: 'Converts a list of bytes to a string.', + args: [ + { + name: 'data', + description: 'The list of bytes to convert', + type: 'list' + } + ], + return: { + description: 'The string representation of the bytes', + type: 'string' + } + }, + decodeToJson: { + meta: 'function', + description: 'Parses a JSON string or converts a list of bytes to a string and parses it as JSON.', + args: [ + { + name: 'data', + description: 'The JSON string or list of bytes to parse into JSON object', + type: 'string | list' + } + ], + return: { + description: 'The parsed JSON object', + type: 'object' + } + }, + stringToBytes: { + meta: 'function', + description: 'Converts a string to a list of bytes, optionally specifying the charset.', + args: [ + { + name: 'str', + description: 'The string to convert', + type: 'string' + }, + { + name: 'charsetName', + description: 'The charset to use for conversion (e.g., "UTF-8")', + type: 'string', + optional: true + } + ], + return: { + description: 'The list of bytes representing the string', + type: 'list' + } + }, + parseInt: { + meta: 'function', + description: 'Parses a string to an integer, optionally specifying the radix.', + args: [ + { + name: 'str', + description: 'The string to parse', + type: 'string' + }, + { + name: 'radix', + description: 'The radix for parsing (e.g., 2 for binary, 16 for hex). Defaults to auto-detected (e.g., 0x for hex).', + type: 'number', + optional: true + } + ], + return: { + description: 'The parsed integer, or null if invalid', + type: 'number' + } + }, + parseLong: { + meta: 'function', + description: 'Parses a string to a long integer, optionally specifying the radix.', + args: [ + { + name: 'str', + description: 'The string to parse', + type: 'string' + }, + { + name: 'radix', + description: 'The radix for parsing (e.g., 2 for binary, 16 for hex). Defaults to auto-detected (e.g., 0x for hex).', + type: 'number', + optional: true + } + ], + return: { + description: 'The parsed long integer, or null if invalid', + type: 'number' + } + }, + parseFloat: { + meta: 'function', + description: 'Parses a string to a float, optionally specifying the radix.', + args: [ + { + name: 'str', + description: 'The string to parse', + type: 'string' + }, + { + name: 'radix', + description: 'The radix for parsing (e.g., 16 indicates a standard IEEE 754 hexadecimal float, defaults to 10 if unspecified)', + type: 'number', + optional: true + } + ], + return: { + description: 'The parsed float, or null if invalid', + type: 'number' + } + }, + parseHexIntLongToFloat: { + meta: 'function', + description: 'Parses a hexadecimal string to a float, treating it as an integer value.', + args: [ + { + name: 'hex', + description: 'The hexadecimal string to parse (e.g., "0x0A" for 10.0)', + type: 'string' + }, + { + name: 'bigEndian', + description: 'Whether to interpret the string in big-endian order', + type: 'boolean' + } + ], + return: { + description: 'The parsed float, or null if invalid', + type: 'number' + } + }, + parseDouble: { + meta: 'function', + description: 'Parses a string to a double, optionally specifying the radix.', + args: [ + { + name: 'str', + description: 'The string to parse', + type: 'string' + }, + { + name: 'radix', + description: 'The radix for parsing (e.g., 16 indicates a standard IEEE 754 double bits, defaults to 10 if unspecified)', + type: 'number', + optional: true + } + ], + return: { + description: 'The parsed double, or null if invalid', + type: 'number' + } + }, + parseLittleEndianHexToInt: { + meta: 'function', + description: 'Parses a little-endian hexadecimal string to an integer.', + args: [ + { + name: 'hex', + description: 'The hexadecimal string to parse', + type: 'string' + } + ], + return: { + description: 'The parsed integer', + type: 'number' + } + }, + parseBigEndianHexToInt: { + meta: 'function', + description: 'Parses a big-endian hexadecimal string to an integer.', + args: [ + { + name: 'hex', + description: 'The hexadecimal string to parse', + type: 'string' + } + ], + return: { + description: 'The parsed integer', + type: 'number' + } + }, + parseHexToInt: { + meta: 'function', + description: 'Parses a hexadecimal string to an integer, optionally specifying endianness.', + args: [ + { + name: 'hex', + description: 'The hexadecimal string to parse', + type: 'string' + }, + { + name: 'bigEndian', + description: 'Whether to interpret the string in big-endian order (defaults to true)', + type: 'boolean', + optional: true + } + ], + return: { + description: 'The parsed integer', + type: 'number' + } + }, + parseBytesToInt: { + meta: 'function', + description: 'Parses a list or array of bytes to an integer.', + args: [ + { + name: 'data', + description: 'The bytes to parse', + type: 'list | array' + }, + { + name: 'offset', + description: 'The starting index in the byte list or array (defaults to 0)', + type: 'number', + optional: true + }, + { + name: 'length', + description: 'The number of bytes to parse (max 4)', + type: 'number', + optional: true + }, + { + name: 'bigEndian', + description: 'Whether to interpret bytes in big-endian order (defaults to true)', + type: 'boolean', + optional: true + } + ], + return: { + description: 'The parsed integer', + type: 'number' + } + }, + parseLittleEndianHexToLong: { + meta: 'function', + description: 'Parses a little-endian hexadecimal string to a long integer.', + args: [ + { + name: 'hex', + description: 'The hexadecimal string to parse', + type: 'string' + } + ], + return: { + description: 'The parsed long integer', + type: 'number' + } + }, + parseBigEndianHexToLong: { + meta: 'function', + description: 'Parses a big-endian hexadecimal string to a long integer.', + args: [ + { + name: 'hex', + description: 'The hexadecimal string to parse', + type: 'string' + } + ], + return: { + description: 'The parsed long integer', + type: 'number' + } + }, + parseHexToLong: { + meta: 'function', + description: 'Parses a hexadecimal string to a long integer, optionally specifying endianness.', + args: [ + { + name: 'hex', + description: 'The hexadecimal string to parse', + type: 'string' + }, + { + name: 'bigEndian', + description: 'Whether to interpret the string in big-endian order (defaults to true)', + type: 'boolean', + optional: true + } + ], + return: { + description: 'The parsed long integer', + type: 'number' + } + }, + parseBytesToLong: { + meta: 'function', + description: 'Parses a list or array of bytes to a long integer.', + args: [ + { + name: 'data', + description: 'The bytes to parse', + type: 'list | array' + }, + { + name: 'offset', + description: 'The starting index in the byte list or array (defaults to 0)', + type: 'number', + optional: true + }, + { + name: 'length', + description: 'The number of bytes to parse (max 8)', + type: 'number', + optional: true + }, + { + name: 'bigEndian', + description: 'Whether to interpret bytes in big-endian order (defaults to true)', + type: 'boolean', + optional: true + } + ], + return: { + description: 'The parsed long integer', + type: 'number' + } + }, + parseLittleEndianHexToFloat: { + meta: 'function', + description: 'Parses a little-endian hexadecimal string to a float using IEEE 754 format.', + args: [ + { + name: 'hex', + description: 'The hexadecimal string to parse', + type: 'string' + } + ], + return: { + description: 'The parsed float', + type: 'number' + } + }, + parseBigEndianHexToFloat: { + meta: 'function', + description: 'Parses a big-endian hexadecimal string to a float using IEEE 754 format.', + args: [ + { + name: 'hex', + description: 'The hexadecimal string to parse', + type: 'string' + } + ], + return: { + description: 'The parsed float', + type: 'number' + } + }, + parseHexToFloat: { + meta: 'function', + description: 'Parses a hexadecimal string to a float using IEEE 754 format, optionally specifying endianness.', + args: [ + { + name: 'hex', + description: 'The hexadecimal string to parse', + type: 'string' + }, + { + name: 'bigEndian', + description: 'Whether to interpret the string in big-endian order (defaults to true)', + type: 'boolean', + optional: true + } + ], + return: { + description: 'The parsed float', + type: 'number' + } + }, + parseBytesToFloat: { + meta: 'function', + description: 'Parses a list or array of bytes to a float using IEEE 754 format.', + args: [ + { + name: 'data', + description: 'The bytes to parse', + type: 'list | array' + }, + { + name: 'offset', + description: 'The starting index in the byte list or array (defaults to 0)', + type: 'number', + optional: true + }, + { + name: 'length', + description: 'The number of bytes to parse (max 4)', + type: 'number', + optional: true + }, + { + name: 'bigEndian', + description: 'Whether to interpret bytes in big-endian order (defaults to true)', + type: 'boolean', + optional: true + } + ], + return: { + description: 'The parsed float', + type: 'number' + } + }, + parseBytesIntToFloat: { + meta: 'function', + description: 'Parses a list or array of bytes to a float by first interpreting them as an integer.', + args: [ + { + name: 'data', + description: 'The bytes to parse', + type: 'list | array' + }, + { + name: 'offset', + description: 'The starting index in the byte list or array (defaults to 0)', + type: 'number', + optional: true + }, + { + name: 'length', + description: 'The number of bytes to parse (max 4)', + type: 'number', + optional: true + }, + { + name: 'bigEndian', + description: 'Whether to interpret bytes in big-endian order (defaults to true)', + type: 'boolean', + optional: true + } + ], + return: { + description: 'The parsed float', + type: 'number' + } + }, + parseLittleEndianHexToDouble: { + meta: 'function', + description: 'Parses a little-endian hexadecimal string to a double using IEEE 754 format.', + args: [ + { + name: 'hex', + description: 'The hexadecimal string to parse', + type: 'string' + } + ], + return: { + description: 'The parsed double', + type: 'number' + } + }, + parseBigEndianHexToDouble: { + meta: 'function', + description: 'Parses a big-endian hexadecimal string to a double using IEEE 754 format.', + args: [ + { + name: 'hex', + description: 'The hexadecimal string to parse', + type: 'string' + } + ], + return: { + description: 'The parsed double', + type: 'number' + } + }, + parseHexToDouble: { + meta: 'function', + description: 'Parses a hexadecimal string to a double using IEEE 754 format, optionally specifying endianness.', + args: [ + { + name: 'hex', + description: 'The hexadecimal string to parse', + type: 'string' + }, + { + name: 'bigEndian', + description: 'Whether to interpret the string in big-endian order (defaults to true)', + type: 'boolean', + optional: true + } + ], + return: { + description: 'The parsed double', + type: 'number' + } + }, + parseBytesToDouble: { + meta: 'function', + description: 'Parses a list or array of bytes to a double using IEEE 754 format.', + args: [ + { + name: 'data', + description: 'The bytes to parse', + type: 'list | array' + }, + { + name: 'offset', + description: 'The starting index in the byte list or array (defaults to 0)', + type: 'number', + optional: true + }, + { + name: 'length', + description: 'The number of bytes to parse (max 8)', + type: 'number', + optional: true + }, + { + name: 'bigEndian', + description: 'Whether to interpret bytes in big-endian order (defaults to true)', + type: 'boolean', + optional: true + } + ], + return: { + description: 'The parsed double', + type: 'number' + } + }, + parseBytesLongToDouble: { + meta: 'function', + description: 'Parses a list or array of bytes to a double by first interpreting them as a long integer.', + args: [ + { + name: 'data', + description: 'The bytes to parse', + type: 'list | array' + }, + { + name: 'offset', + description: 'The starting index in the byte list or array (defaults to 0)', + type: 'number', + optional: true + }, + { + name: 'length', + description: 'The number of bytes to parse (max 8)', + type: 'number', + optional: true + }, + { + name: 'bigEndian', + description: 'Whether to interpret bytes in big-endian order (defaults to true)', + type: 'boolean', + optional: true + } + ], + return: { + description: 'The parsed double', + type: 'number' + } + }, + toFixed: { + meta: 'function', + description: 'Rounds a floating-point number to a set precision using half-up rounding.', + args: [ + { + name: 'value', + description: 'The floating-point number', + type: 'number' + }, + { + name: 'precision', + description: 'The number of decimal places', + type: 'number' + } + ], + return: { + description: 'The rounded floating-point number.', + type: 'number' + } + }, + toInt: { + meta: 'function', + description: 'Converts a floating-point number to an integer by half-up rounding.', + args: [ + { + name: 'value', + description: 'The floating-point number to convert', + type: 'number' + } + ], + return: { + description: 'The rounded integer', + type: 'number' + } + }, + hexToBytes: { + meta: 'function', + description: 'Converts a hexadecimal string to a list of bytes.', + args: [ + { + name: 'hex', + description: 'The hexadecimal string to convert', + type: 'string' + } + ], + return: { + description: 'The list of bytes', + type: 'list' + } + }, + hexToBytesArray: { + meta: 'function', + description: 'Converts a hexadecimal string to an array of bytes.', + args: [ + { + name: 'hex', + description: 'The hexadecimal string to convert', + type: 'string' + } + ], + return: { + description: 'The array of bytes', + type: 'array' + } + }, + intToHex: { + meta: 'function', + description: 'Converts an integer to a hexadecimal string.', + args: [ + { + name: 'value', + description: 'The integer to convert', + type: 'number' + }, + { + name: 'bigEndian', + description: 'Whether to use big-endian order (defaults to true)', + type: 'boolean', + optional: true + }, + { + name: 'prefix', + description: 'Whether to prefix with "0x" (defaults to false)', + type: 'boolean', + optional: true + }, + { + name: 'length', + description: 'The desired length of the hex string (defaults to minimum required)', + type: 'number', + optional: true + } + ], + return: { + description: 'The hexadecimal string', + type: 'string' + } + }, + longToHex: { + meta: 'function', + description: 'Converts a long integer to a hexadecimal string.', + args: [ + { + name: 'value', + description: 'The long integer to convert', + type: 'number' + }, + { + name: 'bigEndian', + description: 'Whether to use big-endian order (defaults to true)', + type: 'boolean', + optional: true + }, + { + name: 'prefix', + description: 'Whether to prefix with "0x" (defaults to false)', + type: 'boolean', + optional: true + }, + { + name: 'length', + description: 'The desired length of the hex string (defaults to minimum required)', + type: 'number', + optional: true + } + ], + return: { + description: 'The hexadecimal string', + type: 'string' + } + }, + intLongToRadixString: { + meta: 'function', + description: 'Converts a long integer to a string in the specified radix.', + args: [ + { + name: 'value', + description: 'The number to convert', + type: 'number' + }, + { + name: 'radix', + description: 'The radix for conversion (e.g., 2 for binary, 16 for hex). Defaults to 10.', + type: 'number', + optional: true + }, + { + name: 'bigEndian', + description: 'Whether to use big-endian order for hex (defaults to true)', + type: 'boolean', + optional: true + }, + { + name: 'prefix', + description: 'Whether to prefix hex with "0x" (defaults to false)', + type: 'boolean', + optional: true + } + ], + return: { + description: 'The string representation in the specified radix', + type: 'string' + } + }, + floatToHex: { + meta: 'function', + description: 'Converts a float to its IEEE 754 hexadecimal representation.', + args: [ + { + name: 'value', + description: 'The float to convert', + type: 'number' + }, + { + name: 'bigEndian', + description: 'Whether to use big-endian order (defaults to true)', + type: 'boolean', + optional: true + } + ], + return: { + description: 'The hexadecimal string', + type: 'string' + } + }, + doubleToHex: { + meta: 'function', + description: 'Converts a double to its IEEE 754 hexadecimal representation.', + args: [ + { + name: 'value', + description: 'The double to convert', + type: 'number' + }, + { + name: 'bigEndian', + description: 'Whether to use big-endian order (defaults to true)', + type: 'boolean', + optional: true + } + ], + return: { + description: 'The hexadecimal string', + type: 'string' + } + }, + printUnsignedBytes: { + meta: 'function', + description: 'Converts a list of signed bytes to a list of unsigned integer values.', + args: [ + { + name: 'data', + description: 'The list of bytes to convert', + type: 'list' + } + ], + return: { + description: 'The list of unsigned integers (0-255)', + type: 'list' + } + }, + base64ToHex: { + meta: 'function', + description: 'Converts a Base64 string to a hexadecimal string.', + args: [ + { + name: 'str', + description: 'The Base64 string to convert', + type: 'string' + } + ], + return: { + description: 'The hexadecimal string', + type: 'string' + } + }, + hexToBase64: { + meta: 'function', + description: 'Converts a hexadecimal string to a Base64 string.', + args: [ + { + name: 'hex', + description: 'The hexadecimal string to convert', + type: 'string' + } + ], + return: { + description: 'The Base64 string', + type: 'string' + } + }, + base64ToBytes: { + meta: 'function', + description: 'Converts a Base64 string to an array of bytes.', + args: [ + { + name: 'str', + description: 'The Base64 string to convert', + type: 'string' + } + ], + return: { + description: 'The array of bytes', + type: 'array' + } + }, + base64ToBytesList: { + meta: 'function', + description: 'Converts a Base64 string to a list of bytes.', + args: [ + { + name: 'str', + description: 'The Base64 string to convert', + type: 'string' + } + ], + return: { + description: 'The list of bytes', + type: 'list' + } + }, + bytesToBase64: { + meta: 'function', + description: 'Converts an array of bytes to a Base64 string.', + args: [ + { + name: 'data', + description: 'The array of bytes to convert', + type: 'array' + } + ], + return: { + description: 'The Base64 string', + type: 'string' + } + }, + bytesToHex: { + meta: 'function', + description: 'Converts a list or array of bytes to a hexadecimal string.', + args: [ + { + name: 'data', + description: 'The bytes to convert', + type: 'list | array' + } + ], + return: { + description: 'The hexadecimal string', + type: 'string' + } + }, + toFlatMap: { + meta: 'function', + description: 'Converts a nested map to a flat map, with customizable key paths and exclusions', + args: [ + { + name: 'json', + description: 'The nested map to flatten', + type: 'object' + }, + { + name: 'excludeKeys', + description: 'List of keys to exclude from flattening', + type: 'list', + optional: true + }, + { + name: 'pathInKey', + description: 'Whether to include full path in keys (defaults to true)', + type: 'boolean', + optional: true + } + ], + return: { + description: 'The flattened map', + type: 'object' + } + }, + encodeURI: { + meta: 'function', + description: 'Encodes a URI string, preserving certain characters as per MDN standards.', + args: [ + { + name: 'str', + description: 'The URI string to encode', + type: 'string' + } + ], + return: { + description: 'The encoded URI string', + type: 'string' + } + }, + decodeURI: { + meta: 'function', + description: 'Decodes a URI string previously encoded.', + args: [ + { + name: 'str', + description: 'The URI string to decode', + type: 'string' + } + ], + return: { + description: 'The decoded URI string', + type: 'string' + } + }, + raiseError: { + meta: 'function', + description: 'Throws an error with a custom message.', + args: [ + { + name: 'str', + description: 'The error message to throw', + type: 'string' + } + ], + return: { + description: 'Does not return; throws an exception', + type: 'void' + } + }, + isBinary: { + meta: 'function', + description: 'Checks if a string is a binary number.', + args: [ + { + name: 'str', + description: 'The string to check', + type: 'string' + } + ], + return: { + description: '2 if the string is binary, -1 otherwise', + type: 'number' + } + }, + isOctal: { + meta: 'function', + description: 'Checks if a string is an octal number.', + args: [ + { + name: 'str', + description: 'The string to check', + type: 'string' + } + ], + return: { + description: '8 if the string is octal, -1 otherwise', + type: 'number' + } + }, + isDecimal: { + meta: 'function', + description: 'Checks if a string is a decimal number.', + args: [ + { + name: 'str', + description: 'The string to check', + type: 'string' + } + ], + return: { + description: '10 if the string is decimal, -1 otherwise', + type: 'number' + } + }, + isHexadecimal: { + meta: 'function', + description: 'Checks if a string is a hexadecimal number.', + args: [ + { + name: 'str', + description: 'The string to check', + type: 'string' + } + ], + return: { + description: '16 if the string is hexadecimal, -1 otherwise', + type: 'number' + } + }, + bytesToExecutionArrayList: { + meta: 'function', + description: 'Converts an array of bytes to a list.', + args: [ + { + name: 'data', + description: 'The array of bytes to convert', + type: 'array' + } + ], + return: { + description: 'The list of bytes', + type: 'list' + } + }, + padStart: { + meta: 'function', + description: 'Pads the start of a string with a character until it reaches the target length.', + args: [ + { + name: 'str', + description: 'The string to pad', + type: 'string' + }, + { + name: 'length', + description: 'The desired length of the resulting string', + type: 'number' + }, + { + name: 'padString', + description: 'The character to pad with (single character)', + type: 'string' + } + ], + return: { + description: 'The padded string', + type: 'string' + } + }, + padEnd: { + meta: 'function', + description: 'Pads the end of a string with a character until it reaches the target length.', + args: [ + { + name: 'str', + description: 'The string to pad', + type: 'string' + }, + { + name: 'length', + description: 'The desired length of the resulting string', + type: 'number' + }, + { + name: 'padString', + description: 'The character to pad with (single character)', + type: 'string' + } + ], + return: { + description: 'The padded string', + type: 'string' + } + }, + parseByteToBinaryArray: { + meta: 'function', + description: 'Converts a byte to a binary array.', + args: [ + { + name: 'value', + description: 'The byte value to convert', + type: 'number' + }, + { + name: 'length', + description: 'The length of the binary array (defaults to 8)', + type: 'number', + optional: true + }, + { + name: 'bigEndian', + description: 'Whether to use big-endian order (defaults to true)', + type: 'boolean', + optional: true + } + ], + return: { + description: 'The binary array', + type: 'array' + } + }, + parseBytesToBinaryArray: { + meta: 'function', + description: 'Converts a list or array of bytes to a binary array.', + args: [ + { + name: 'data', + description: 'The bytes to convert', + type: 'list | array' + }, + { + name: 'length', + description: 'The total length of the binary array (defaults to bytes.length * 8)', + type: 'number', + optional: true + } + ], + return: { + description: 'The binary array', + type: 'array' + } + }, + parseLongToBinaryArray: { + meta: 'function', + description: 'Converts a long integer to a binary array.', + args: [ + { + name: 'value', + description: 'The long integer to convert', + type: 'number' + }, + { + name: 'length', + description: 'The length of the binary array (defaults to 64)', + type: 'number', + optional: true + } + ], + return: { + description: 'The binary array', + type: 'array' + } + }, + parseBinaryArrayToInt: { + meta: 'function', + description: 'Converts a binary list or array to an integer.', + args: [ + { + name: 'data', + description: 'The binary list or array to convert', + type: 'list | array' + }, + { + name: 'offset', + description: 'The starting index in the binary list or array (defaults to 0)', + type: 'number', + optional: true + }, + { + name: 'length', + description: 'The number of bits to parse (defaults to array length)', + type: 'number', + optional: true + } + ], + return: { + description: 'The parsed integer', + type: 'number' + } + }, + isNaN: { + meta: 'function', + description: 'Checks if the given number is NaN (Not a Number).', + args: [ + { + name: 'value', + description: 'The number to check', + type: 'number' + } + ], + return: { + description: 'True if the number is NaN, false otherwise', + type: 'boolean' + } + }, +} + +export const tbelUtilsAutocompletes = new TbEditorCompleter(tbelEditorCompletions); + +const tbelUtilsFuncNames = Object.keys(tbelEditorCompletions); + +export const tbelUtilsFuncHighlightRules: Array = + tbelUtilsFuncNames.map(funcName => ({ + token: 'tb.tbel-utils-func', + regex: `\\b${funcName}\\b`, + next: 'no_regex' + })); diff --git a/ui-ngx/src/app/shared/models/alias.models.ts b/ui-ngx/src/app/shared/models/alias.models.ts index 4005e451af..bc325a0692 100644 --- a/ui-ngx/src/app/shared/models/alias.models.ts +++ b/ui-ngx/src/app/shared/models/alias.models.ts @@ -18,6 +18,7 @@ import { EntityType } from '@shared/models/entity-type.models'; import { EntityId } from '@shared/models/id/entity-id'; import { EntitySearchDirection, RelationEntityTypeFilter } from '@shared/models/relation.models'; import { EntityFilter } from '@shared/models/query/query.models'; +import { guid, isEqual } from '@core/utils'; export enum AliasFilterType { singleEntity = 'singleEntity', @@ -210,3 +211,41 @@ export interface EntityAliasFilterResult { entityFilter: EntityFilter; entityParamName?: string; } + +export const getEntityAliasId = (entityAliases: EntityAliases, aliasInfo: EntityAliasInfo): string => { + let newAliasId: string; + for (const aliasId of Object.keys(entityAliases)) { + if (isEntityAliasEqual(entityAliases[aliasId], aliasInfo)) { + newAliasId = aliasId; + break; + } + } + if (!newAliasId) { + const newAliasName = createEntityAliasName(entityAliases, aliasInfo.alias); + newAliasId = guid(); + entityAliases[newAliasId] = {id: newAliasId, alias: newAliasName, filter: aliasInfo.filter}; + } + return newAliasId; +} + +const isEntityAliasEqual = (alias1: EntityAliasInfo, alias2: EntityAliasInfo): boolean => { + return isEqual(alias1.filter, alias2.filter); +} + +const createEntityAliasName = (entityAliases: EntityAliases, alias: string): string => { + let c = 0; + let newAlias = alias; + let unique = false; + while (!unique) { + unique = true; + for (const entAliasId of Object.keys(entityAliases)) { + const entAlias = entityAliases[entAliasId]; + if (newAlias === entAlias.alias) { + c++; + newAlias = alias + c; + unique = false; + } + } + } + return newAlias; +} diff --git a/ui-ngx/src/app/shared/models/calculated-field.models.ts b/ui-ngx/src/app/shared/models/calculated-field.models.ts new file mode 100644 index 0000000000..383f7c7f72 --- /dev/null +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -0,0 +1,645 @@ +/// +/// 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 { + HasEntityDebugSettings, + HasTenantId, + HasVersion +} from '@shared/models/entity.models'; +import { BaseData, ExportableEntity } from '@shared/models/base-data'; +import { CalculatedFieldId } from '@shared/models/id/calculated-field-id'; +import { EntityId } from '@shared/models/id/entity-id'; +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { AliasFilterType } from '@shared/models/alias.models'; +import { Observable } from 'rxjs'; +import { TbEditorCompleter } from '@shared/models/ace/completion.models'; +import { + AceHighlightRule, + AceHighlightRules, + dotOperatorHighlightRule, + endGroupHighlightRule +} from '@shared/models/ace/ace.models'; + +export interface CalculatedField extends Omit, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity { + configuration: CalculatedFieldConfiguration; + type: CalculatedFieldType; + entityId: EntityId; +} + +export enum CalculatedFieldType { + SIMPLE = 'SIMPLE', + SCRIPT = 'SCRIPT', +} + +export const CalculatedFieldTypeTranslations = new Map( + [ + [CalculatedFieldType.SIMPLE, 'calculated-fields.type.simple'], + [CalculatedFieldType.SCRIPT, 'calculated-fields.type.script'], + ] +) + +export interface CalculatedFieldConfiguration { + type: CalculatedFieldType; + expression: string; + arguments: Record; + output: CalculatedFieldOutput; +} + +export interface CalculatedFieldOutput { + type: OutputType; + name: string; + scope?: AttributeScope; + decimalsByDefault?: number; +} + +export enum ArgumentEntityType { + Current = 'CURRENT', + Device = 'DEVICE', + Asset = 'ASSET', + Customer = 'CUSTOMER', + Tenant = 'TENANT', +} + +export const ArgumentEntityTypeTranslations = new Map( + [ + [ArgumentEntityType.Current, 'calculated-fields.argument-current'], + [ArgumentEntityType.Device, 'calculated-fields.argument-device'], + [ArgumentEntityType.Asset, 'calculated-fields.argument-asset'], + [ArgumentEntityType.Customer, 'calculated-fields.argument-customer'], + [ArgumentEntityType.Tenant, 'calculated-fields.argument-tenant'], + ] +) + +export enum ArgumentType { + Attribute = 'ATTRIBUTE', + LatestTelemetry = 'TS_LATEST', + Rolling = 'TS_ROLLING', +} + +export enum TestArgumentType { + Single = 'SINGLE_VALUE', + Rolling = 'TS_ROLLING', +} + +export const TestArgumentTypeMap = new Map( + [ + [ArgumentType.Attribute, TestArgumentType.Single], + [ArgumentType.LatestTelemetry, TestArgumentType.Single], + [ArgumentType.Rolling, TestArgumentType.Rolling], + ] +) + +export enum OutputType { + Attribute = 'ATTRIBUTES', + Timeseries = 'TIME_SERIES', +} + +export const OutputTypeTranslations = new Map( + [ + [OutputType.Attribute, 'calculated-fields.attribute'], + [OutputType.Timeseries, 'calculated-fields.timeseries'], + ] +) + +export const ArgumentTypeTranslations = new Map( + [ + [ArgumentType.Attribute, 'calculated-fields.attribute'], + [ArgumentType.LatestTelemetry, 'calculated-fields.latest-telemetry'], + [ArgumentType.Rolling, 'calculated-fields.rolling'], + ] +) + +export interface CalculatedFieldArgument { + refEntityKey: RefEntityKey; + defaultValue?: string; + refEntityId?: RefEntityId; + limit?: number; + timeWindow?: number; +} + +export interface RefEntityKey { + key: string; + type: ArgumentType; + scope?: AttributeScope; +} + +export interface RefEntityId { + entityType: ArgumentEntityType; + id: string; +} + +export interface CalculatedFieldArgumentValue extends CalculatedFieldArgument { + argumentName: string; +} + +export type CalculatedFieldTestScriptFn = (calculatedField: CalculatedField, argumentsObj?: Record, closeAllOnSave?: boolean) => Observable; + +export interface CalculatedFieldTestScriptInputParams { + arguments: CalculatedFieldEventArguments; + expression: string; +} + +export interface ArgumentEntityTypeParams { + title: string; + entityType: EntityType +} + +export const ArgumentEntityTypeParamsMap =new Map([ + [ArgumentEntityType.Device, { title: 'calculated-fields.device-name', entityType: EntityType.DEVICE }], + [ArgumentEntityType.Asset, { title: 'calculated-fields.asset-name', entityType: EntityType.ASSET }], + [ArgumentEntityType.Customer, { title: 'calculated-fields.customer-name', entityType: EntityType.CUSTOMER }], +]) + +export const getCalculatedFieldCurrentEntityFilter = (entityName: string, entityId: EntityId) => { + switch (entityId.entityType) { + case EntityType.ASSET_PROFILE: + return { + assetTypes: [entityName], + type: AliasFilterType.assetType + }; + case EntityType.DEVICE_PROFILE: + return { + deviceTypes: [entityName], + type: AliasFilterType.deviceType + }; + default: + return { + type: AliasFilterType.singleEntity, + singleEntity: entityId, + }; + } +} + +export interface CalculatedFieldArgumentValueBase { + argumentName: string; + type: ArgumentType; +} + +export interface CalculatedFieldAttributeArgumentValue extends CalculatedFieldArgumentValueBase { + ts: number; + value: ValueType; +} + +export interface CalculatedFieldLatestTelemetryArgumentValue extends CalculatedFieldArgumentValueBase { + ts: number; + value: ValueType; +} + +export interface CalculatedFieldRollingTelemetryArgumentValue extends CalculatedFieldArgumentValueBase { + timeWindow: { startTs: number; endTs: number; }; + values: CalculatedFieldSingleArgumentValue[]; +} + +export type CalculatedFieldSingleArgumentValue = CalculatedFieldAttributeArgumentValue & CalculatedFieldLatestTelemetryArgumentValue; + +export type CalculatedFieldArgumentEventValue = CalculatedFieldAttributeArgumentValue | CalculatedFieldLatestTelemetryArgumentValue | CalculatedFieldRollingTelemetryArgumentValue; + +export type CalculatedFieldEventArguments = Record>; + +export const CalculatedFieldCtxLatestTelemetryArgumentAutocomplete = { + meta: 'object', + type: '{ ts: number; value: any; }', + description: 'Calculated field context latest telemetry value argument.', + children: { + ts: { + meta: 'number', + type: 'number', + description: 'Time stamp', + }, + value: { + meta: 'any', + type: 'any', + description: 'Value', + } + }, +}; + +export const CalculatedFieldCtxAttributeValueArgumentAutocomplete = { + meta: 'object', + type: '{ ts: number; value: any; }', + description: 'Calculated field context attribute value argument.', + children: { + ts: { + meta: 'number', + type: 'number', + description: 'Time stamp', + }, + value: { + meta: 'any', + type: 'any', + description: 'Value', + } + }, +}; + +export const CalculatedFieldLatestTelemetryArgumentAutocomplete = { + meta: 'any', + type: 'any', + description: 'Calculated field latest telemetry argument value.', +}; + +export const CalculatedFieldAttributeValueArgumentAutocomplete = { + meta: 'any', + type: 'any', + description: 'Calculated field attribute argument value.', +}; + +export const CalculatedFieldRollingValueArgumentFunctionsAutocomplete = { + max: { + meta: 'function', + description: 'Returns the maximum value of the rolling argument values. Returns NaN if any value is NaN and ignoreNaN is false.', + args: [ + { + name: 'ignoreNaN', + description: 'Whether to ignore NaN values. Equals true by default.', + type: 'boolean', + optional: true, + } + ], + return: { + description: 'The maximum value, or NaN if applicable', + type: 'number' + } + }, + min: { + meta: 'function', + description: 'Returns the minimum value of the rolling argument values. Returns NaN if any value is NaN and ignoreNaN is false.', + args: [ + { + name: 'ignoreNaN', + description: 'Whether to ignore NaN values. Equals true by default.', + type: 'boolean', + optional: true, + } + ], + return: { + description: 'The minimum value, or NaN if applicable', + type: 'number' + } + }, + mean: { + meta: 'function', + description: 'Computes the mean value of the rolling argument values. Returns NaN if any value is NaN and ignoreNaN is false.', + args: [ + { + name: 'ignoreNaN', + description: 'Whether to ignore NaN values. Equals true by default.', + type: 'boolean', + optional: true, + } + ], + return: { + description: 'The mean value, or NaN if applicable', + type: 'number' + } + }, + avg: { + meta: 'function', + description: 'Computes the average value of the rolling argument values. Returns NaN if any value is NaN and ignoreNaN is false.', + args: [ + { + name: 'ignoreNaN', + description: 'Whether to ignore NaN values. Equals true by default.', + type: 'boolean', + optional: true, + } + ], + return: { + description: 'The average value, or NaN if applicable', + type: 'number' + } + }, + std: { + meta: 'function', + description: 'Computes the standard deviation of the rolling argument values. Returns NaN if any value is NaN and ignoreNaN is false.', + args: [ + { + name: 'ignoreNaN', + description: 'Whether to ignore NaN values. Equals true by default.', + type: 'boolean', + optional: true, + } + ], + return: { + description: 'The standard deviation, or NaN if applicable', + type: 'number' + } + }, + median: { + meta: 'function', + description: 'Computes the median value of the rolling argument values. Returns NaN if any value is NaN and ignoreNaN is false.', + args: [ + { + name: 'ignoreNaN', + description: 'Whether to ignore NaN values. Equals true by default.', + type: 'boolean', + optional: true, + } + ], + return: { + description: 'The median value, or NaN if applicable', + type: 'number' + } + }, + count: { + meta: 'function', + description: 'Counts values of the rolling argument. Counts non-NaN values if ignoreNaN is true, otherwise - total size.', + args: [ + { + name: 'ignoreNaN', + description: 'Whether to ignore NaN values. Equals true by default.', + type: 'boolean', + optional: true, + } + ], + return: { + description: 'The count of values', + type: 'number' + } + }, + last: { + meta: 'function', + description: 'Returns the last non-NaN value of the rolling argument values if ignoreNaN is true, otherwise - the last value.', + args: [ + { + name: 'ignoreNaN', + description: 'Whether to ignore NaN values. Equals true by default.', + type: 'boolean', + optional: true, + } + ], + return: { + description: 'The last value, or NaN if applicable', + type: 'number' + } + }, + first: { + meta: 'function', + description: 'Returns the first non-NaN value of the rolling argument values if ignoreNaN is true, otherwise - the first value.', + args: [ + { + name: 'ignoreNaN', + description: 'Whether to ignore NaN values. Equals true by default.', + type: 'boolean', + optional: true, + } + ], + return: { + description: 'The first value, or NaN if applicable', + type: 'number' + } + }, + sum: { + meta: 'function', + description: 'Computes the sum of rolling argument values. Returns NaN if any value is NaN and ignoreNaN is false.', + args: [ + { + name: 'ignoreNaN', + description: 'Whether to ignore NaN values. Equals true by default.', + type: 'boolean', + optional: true, + } + ], + return: { + description: 'The sum of values, or NaN if applicable', + type: 'number' + } + }, + merge: { + meta: 'function', + description: 'Merges current object with other time series rolling argument into a single object by aligning their timestamped values. Supports optional configurable settings.', + args: [ + { + name: 'other', + description: "A time series rolling argument to be merged with the current object.", + type: "object", + optional: true + }, + { + name: "settings", + description: "Optional settings controlling the merging process. Supported keys: 'ignoreNaN' (boolean, equals true by default) to determine whether NaN values should be ignored; 'timeWindow' (object, empty by default) to apply time window filtering.", + type: "object", + optional: true + } + ], + return: { + description: 'A new object containing merged timestamped values from all provided arguments, aligned based on timestamps and filtered according to settings.', + type: '{ values: { ts: number; values: number[]; }[]; timeWindow: { startTs: number; endTs: number } }; }', + } + }, + mergeAll: { + meta: 'function', + description: 'Merges current object with other time series rolling arguments into a single object by aligning their timestamped values. Supports optional configurable settings.', + args: [ + { + name: 'others', + description: "A list of time series rolling arguments to be merged with the current object.", + type: "object[]", + optional: true + }, + { + name: "settings", + description: "Optional settings controlling the merging process. Supported keys: 'ignoreNaN' (boolean, equals true by default) to determine whether NaN values should be ignored; 'timeWindow' (object, empty by default) to apply time window filtering.", + type: "object", + optional: true + } + ], + return: { + description: 'A new object containing merged timestamped values from all provided arguments, aligned based on timestamps and filtered according to settings.', + type: '{ values: { ts: number; values: number[]; }[]; timeWindow: { startTs: number; endTs: number } }; }', + } + } +}; + +export const CalculatedFieldRollingValueArgumentAutocomplete = { + meta: 'object', + type: '{ values: { ts: number; value: number; }[]; timeWindow: { startTs: number; endTs: number } }; }', + description: 'Calculated field rolling value argument.', + children: { + ...CalculatedFieldRollingValueArgumentFunctionsAutocomplete, + values: { + meta: 'array', + type: '{ ts: number; value: any; }[]', + description: 'Values array', + }, + timeWindow: { + meta: 'object', + type: '{ startTs: number; endTs: number }', + description: 'Time window configuration', + children: { + startTs: { + meta: 'number', + type: 'number', + description: 'Start time stamp', + }, + endTs: { + meta: 'number', + type: 'number', + description: 'End time stamp', + } + } + } + }, +}; + +export const getCalculatedFieldArgumentsEditorCompleter = (argumentsObj: Record): TbEditorCompleter => { + return new TbEditorCompleter(Object.keys(argumentsObj).reduce((acc, key) => { + switch (argumentsObj[key].refEntityKey.type) { + case ArgumentType.Attribute: + acc[key] = CalculatedFieldAttributeValueArgumentAutocomplete; + acc.ctx.children.args.children[key] = CalculatedFieldCtxAttributeValueArgumentAutocomplete; + break; + case ArgumentType.LatestTelemetry: + acc[key] = CalculatedFieldLatestTelemetryArgumentAutocomplete; + acc.ctx.children.args.children[key] = CalculatedFieldCtxLatestTelemetryArgumentAutocomplete; + break; + case ArgumentType.Rolling: + acc[key] = CalculatedFieldRollingValueArgumentAutocomplete; + acc.ctx.children.args.children[key] = CalculatedFieldRollingValueArgumentAutocomplete; + break; + } + return acc; + }, { + ctx: { + meta: 'object', + type: '{ args: { [key: string]: object } }', + description: 'Calculated field context.', + children: { + args: { + meta: 'object', + type: '{ [key: string]: object }', + description: 'Calculated field context arguments.', + children: {} + } + } + } + })); +} + +export const getCalculatedFieldArgumentsHighlights = ( + argumentsObj: Record +): AceHighlightRules => { + const calculatedFieldArgumentsKeys = Object.keys(argumentsObj).map(key => ({ + token: 'tb.calculated-field-key', + regex: `\\b${key}\\b`, + next: argumentsObj[key].refEntityKey.type === ArgumentType.Rolling + ? 'calculatedFieldRollingArgumentValue' + : 'no_regex' + })); + const calculatedFieldCtxArgumentsHighlightRules = { + calculatedFieldCtxArgs: [ + dotOperatorHighlightRule, + ...calculatedFieldArgumentsKeys.map(argumentRule => argumentRule.next === 'no_regex' ? {...argumentRule, next: 'calculatedFieldSingleArgumentValue' } : argumentRule), + endGroupHighlightRule + ] + }; + + return { + start: [ + calculatedFieldArgumentsContextHighlightRules, + ...calculatedFieldArgumentsKeys, + ], + ...calculatedFieldArgumentsContextValueHighlightRules, + ...calculatedFieldCtxArgumentsHighlightRules, + ...calculatedFieldSingleArgumentValueHighlightRules, + ...calculatedFieldRollingArgumentValueHighlightRules, + ...calculatedFieldTimeWindowArgumentValueHighlightRules + }; +}; + +const calculatedFieldArgumentsContextHighlightRules: AceHighlightRule = { + token: 'tb.calculated-field-ctx', + regex: /ctx/, + next: 'calculatedFieldCtxValue' +} + +const calculatedFieldArgumentsContextValueHighlightRules: AceHighlightRules = { + calculatedFieldCtxValue: [ + dotOperatorHighlightRule, + { + token: 'tb.calculated-field-args', + regex: /args/, + next: 'calculatedFieldCtxArgs' + }, + endGroupHighlightRule + ] +} + +const calculatedFieldSingleArgumentValueHighlightRules: AceHighlightRules = { + calculatedFieldSingleArgumentValue: [ + dotOperatorHighlightRule, + { + token: 'tb.calculated-field-value', + regex: /value/, + next: 'no_regex' + }, + { + token: 'tb.calculated-field-ts', + regex: /ts/, + next: 'no_regex' + }, + endGroupHighlightRule + ], +} + +const calculatedFieldRollingArgumentValueFunctionsHighlightRules: Array = + Object.keys(CalculatedFieldRollingValueArgumentFunctionsAutocomplete).map(funcName => ({ + token: 'tb.calculated-field-func', + regex: `\\b${funcName}\\b`, + next: 'no_regex' + })); + +const calculatedFieldRollingArgumentValueHighlightRules: AceHighlightRules = { + calculatedFieldRollingArgumentValue: [ + dotOperatorHighlightRule, + { + token: 'tb.calculated-field-values', + regex: /values/, + next: 'no_regex' + }, + { + token: 'tb.calculated-field-time-window', + regex: /timeWindow/, + next: 'calculatedFieldRollingArgumentTimeWindow' + }, + ...calculatedFieldRollingArgumentValueFunctionsHighlightRules, + endGroupHighlightRule + ], +} + +const calculatedFieldTimeWindowArgumentValueHighlightRules: AceHighlightRules = { + calculatedFieldRollingArgumentTimeWindow: [ + dotOperatorHighlightRule, + { + token: 'tb.calculated-field-start-ts', + regex: /startTs/, + next: 'no_regex' + }, + { + token: 'tb.calculated-field-end-ts', + regex: /endTs/, + next: 'no_regex' + }, + endGroupHighlightRule + ] +} + +export const calculatedFieldDefaultScript = + '// Sample script to convert temperature readings from Fahrenheit to Celsius\n' + + 'return {\n' + + ' "temperatureC": (temperatureF - 32) / 1.8\n' + + '};' diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index c811d11052..4dcb1f193b 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -196,6 +196,8 @@ export const HelpLinks = { mobileApplication: `${helpBaseUrl}/docs${docPlatformPrefix}/mobile-center/applications/`, mobileBundle: `${helpBaseUrl}/docs${docPlatformPrefix}/mobile-center/mobile-center/`, mobileQrCode: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/mobile-qr-code/`, + calculatedField: `${helpBaseUrl}/docs${docPlatformPrefix}/`, + timewindowSettings: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/dashboards/#time-window`, } }; /* eslint-enable max-len */ diff --git a/ui-ngx/src/app/shared/models/entity-type.models.ts b/ui-ngx/src/app/shared/models/entity-type.models.ts index fe344352c3..48cad42e7b 100644 --- a/ui-ngx/src/app/shared/models/entity-type.models.ts +++ b/ui-ngx/src/app/shared/models/entity-type.models.ts @@ -49,7 +49,8 @@ export enum EntityType { OAUTH2_CLIENT = 'OAUTH2_CLIENT', DOMAIN = 'DOMAIN', MOBILE_APP_BUNDLE = 'MOBILE_APP_BUNDLE', - MOBILE_APP = 'MOBILE_APP' + MOBILE_APP = 'MOBILE_APP', + CALCULATED_FIELD = 'CALCULATED_FIELD', } export enum AliasEntityType { @@ -478,6 +479,18 @@ export const entityTypeTranslations = new Map( @@ -39,6 +40,7 @@ export const eventTypeTranslations = new Map [EventType.STATS, 'event.type-stats'], [DebugEventType.DEBUG_RULE_NODE, 'event.type-debug-rule-node'], [DebugEventType.DEBUG_RULE_CHAIN, 'event.type-debug-rule-chain'], + [DebugEventType.DEBUG_CALCULATED_FIELD, 'event.type-debug-calculated-field'], ] ); @@ -80,7 +82,7 @@ export interface DebugRuleChainEventBody extends BaseEventBody { error?: string; } -export type EventBody = ErrorEventBody & LcEventEventBody & StatsEventBody & DebugRuleNodeEventBody & DebugRuleChainEventBody; +export type EventBody = ErrorEventBody & LcEventEventBody & StatsEventBody & DebugRuleNodeEventBody & DebugRuleChainEventBody & CalculatedFieldEventBody; export interface Event extends BaseData { tenantId: TenantId; @@ -90,6 +92,16 @@ export interface Event extends BaseData { body: EventBody; } +export interface CalculatedFieldEventBody extends BaseFilterEventBody { + calculatedFieldId: string; + entityId: string; + entityType: EntityType; + arguments: string, + result: string, + msgId: string; + msgType: string; +} + export interface BaseFilterEventBody { server?: string; } diff --git a/ui-ngx/src/app/shared/models/id/calculated-field-id.ts b/ui-ngx/src/app/shared/models/id/calculated-field-id.ts new file mode 100644 index 0000000000..f42e20f3d2 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/calculated-field-id.ts @@ -0,0 +1,26 @@ +/// +/// 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class CalculatedFieldId implements EntityId { + entityType = EntityType.CALCULATED_FIELD; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/public-api.ts b/ui-ngx/src/app/shared/models/public-api.ts index 470b628800..53e4bb286e 100644 --- a/ui-ngx/src/app/shared/models/public-api.ts +++ b/ui-ngx/src/app/shared/models/public-api.ts @@ -61,3 +61,4 @@ export * from './widgets-bundle.model'; export * from './window-message.model'; export * from './usage.models'; export * from './query/query.models'; +export * from './regex.constants'; diff --git a/ui-ngx/src/app/shared/models/query/query.models.ts b/ui-ngx/src/app/shared/models/query/query.models.ts index 98c0cce970..65128fe1eb 100644 --- a/ui-ngx/src/app/shared/models/query/query.models.ts +++ b/ui-ngx/src/app/shared/models/query/query.models.ts @@ -23,6 +23,7 @@ import { EntityType } from '@shared/models/entity-type.models'; import { DataKey, Datasource, DatasourceType } from '@shared/models/widget.models'; import { PageData } from '@shared/models/page/page-data'; import { + guid, isArraysEqualIgnoreUndefined, isDefined, isDefinedAndNotNull, @@ -921,3 +922,42 @@ export function updateDatasourceFromEntityInfo(datasource: Datasource, entity: E } } } + +export const getFilterId = (filters: Filters, filterInfo: FilterInfo): string => { + let newFilterId: string; + for (const filterId of Object.keys(filters)) { + if (isFilterEqual(filters[filterId], filterInfo)) { + newFilterId = filterId; + break; + } + } + if (!newFilterId) { + const newFilterName = createFilterName(filters, filterInfo.filter); + newFilterId = guid(); + filters[newFilterId] = {id: newFilterId, filter: newFilterName, + keyFilters: filterInfo.keyFilters, editable: filterInfo.editable}; + } + return newFilterId; +} + +const isFilterEqual = (filter1: FilterInfo, filter2: FilterInfo): boolean => { + return isEqual(filter1.keyFilters, filter2.keyFilters); +} + +const createFilterName = (filters: Filters, filter: string): string => { + let c = 0; + let newFilter = filter; + let unique = false; + while (!unique) { + unique = true; + for (const entFilterId of Object.keys(filters)) { + const entFilter = filters[entFilterId]; + if (newFilter === entFilter.filter) { + c++; + newFilter = filter + c; + unique = false; + } + } + } + return newFilter; +} diff --git a/ui-ngx/src/app/shared/models/regex.constants.ts b/ui-ngx/src/app/shared/models/regex.constants.ts new file mode 100644 index 0000000000..b8b1be4e83 --- /dev/null +++ b/ui-ngx/src/app/shared/models/regex.constants.ts @@ -0,0 +1,21 @@ +/// +/// 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. +/// + +export const oneSpaceInsideRegex = /^\s*\S+(?:\s\S+)*\s*$/; + +export const charsWithNumRegex = /^[a-zA-Z_]+[a-zA-Z0-9_]*$/; + +export const digitsRegex = /^\d*$/; diff --git a/ui-ngx/src/app/shared/models/rule-node.models.ts b/ui-ngx/src/app/shared/models/rule-node.models.ts index ad7f3c01d5..32422a87e0 100644 --- a/ui-ngx/src/app/shared/models/rule-node.models.ts +++ b/ui-ngx/src/app/shared/models/rule-node.models.ts @@ -25,7 +25,7 @@ import { AfterViewInit, DestroyRef, Directive, EventEmitter, inject, OnInit } fr import { AbstractControl, UntypedFormGroup } from '@angular/forms'; import { RuleChainType } from '@shared/models/rule-chain.models'; import { DebugRuleNodeEventBody } from '@shared/models/event.models'; -import { HasEntityDebugSettings } from '@shared/models/entity.models'; +import { EntityTestScriptResult, HasEntityDebugSettings } from '@shared/models/entity.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; export interface RuleNodeConfiguration { @@ -372,10 +372,7 @@ export interface TestScriptInputParams { msgType: string; } -export interface TestScriptResult { - output: string; - error: string; -} +export type TestScriptResult = EntityTestScriptResult; export enum MessageType { POST_ATTRIBUTES_REQUEST = 'POST_ATTRIBUTES_REQUEST', diff --git a/ui-ngx/src/app/shared/models/tenant.model.ts b/ui-ngx/src/app/shared/models/tenant.model.ts index 23c2d95762..23896141db 100644 --- a/ui-ngx/src/app/shared/models/tenant.model.ts +++ b/ui-ngx/src/app/shared/models/tenant.model.ts @@ -95,6 +95,13 @@ export interface DefaultTenantProfileConfiguration { rpcTtlDays: number; queueStatsTtlDays: number; ruleEngineExceptionsTtlDays: number; + + maxCalculatedFieldsPerEntity: number; + maxArgumentsPerCF: number; + maxDataPointsPerRollingArg: number; + maxStateSizeInKBytes: number; + maxSingleValueArgumentSizeInKBytes: number; + calculatedFieldDebugEventsRateLimit: string; } export type TenantProfileConfigurations = DefaultTenantProfileConfiguration; @@ -148,7 +155,13 @@ export function createTenantProfileConfiguration(type: TenantProfileType): Tenan alarmsTtlDays: 0, rpcTtlDays: 0, queueStatsTtlDays: 0, - ruleEngineExceptionsTtlDays: 0 + ruleEngineExceptionsTtlDays: 0, + maxCalculatedFieldsPerEntity: 5, + maxArgumentsPerCF: 10, + maxDataPointsPerRollingArg: 1000, + maxStateSizeInKBytes: 32, + maxSingleValueArgumentSizeInKBytes: 2, + calculatedFieldDebugEventsRateLimit: '' }; configuration = {...defaultConfiguration, type: TenantProfileType.DEFAULT}; break; diff --git a/ui-ngx/src/app/shared/models/vc.models.ts b/ui-ngx/src/app/shared/models/vc.models.ts index 6cc8de02eb..3795518ffc 100644 --- a/ui-ngx/src/app/shared/models/vc.models.ts +++ b/ui-ngx/src/app/shared/models/vc.models.ts @@ -49,6 +49,7 @@ export interface VersionCreateConfig { saveRelations: boolean; saveAttributes: boolean; saveCredentials: boolean; + saveCalculatedFields: boolean; } export enum VersionCreateRequestType { @@ -106,6 +107,7 @@ export function createDefaultEntityTypesVersionCreate(): {[entityType: string]: syncStrategy: null, saveAttributes: !entityTypesWithoutRelatedData.has(entityType), saveRelations: !entityTypesWithoutRelatedData.has(entityType), + saveCalculatedFields: typesWithCalculatedFields.has(entityType), saveCredentials: true, allEntities: true, entityIds: [] @@ -118,6 +120,7 @@ export interface VersionLoadConfig { loadRelations: boolean; loadAttributes: boolean; loadCredentials: boolean; + loadCalculatedFields: boolean; } export enum VersionLoadRequestType { @@ -154,6 +157,7 @@ export function createDefaultEntityTypesVersionLoad(): {[entityType: string]: En loadAttributes: !entityTypesWithoutRelatedData.has(entityType), loadRelations: !entityTypesWithoutRelatedData.has(entityType), loadCredentials: true, + loadCalculatedFields: typesWithCalculatedFields.has(entityType), removeOtherEntities: false, findExistingEntityByName: true }; @@ -254,4 +258,7 @@ export interface EntityDataInfo { hasRelations: boolean; hasAttributes: boolean; hasCredentials: boolean; + hasCalculatedFields: boolean; } + +export const typesWithCalculatedFields = new Set([EntityType.DEVICE, EntityType.ASSET, EntityType.ASSET_PROFILE, EntityType.DEVICE_PROFILE]); diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index c7f3327f68..44e5ec9c1e 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -42,11 +42,12 @@ import { WidgetConfigComponentData } from '@home/models/widget-component.models' import { ComponentStyle, Font, TimewindowStyle } from '@shared/models/widget-settings.models'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { EntityInfoData, HasTenantId, HasVersion } from '@shared/models/entity.models'; -import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; import { TbFunction } from '@shared/models/js-function.models'; import { FormProperty, jsonFormSchemaToFormProperties } from '@shared/models/dynamic-form.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Device } from '@shared/models/device.models'; export enum widgetType { timeseries = 'timeseries', @@ -185,12 +186,14 @@ export interface WidgetTypeParameters { previewHeight?: string; embedTitlePanel?: boolean; overflowVisible?: boolean; + hideDataTab?: boolean; hideDataSettings?: boolean; defaultDataKeysFunction?: (configComponent: any, configData: any) => DataKey[]; defaultLatestDataKeysFunction?: (configComponent: any, configData: any) => DataKey[]; dataKeySettingsFunction?: DataKeySettingsFunction; displayRpcMessageToast?: boolean; targetDeviceOptional?: boolean; + additionalWidgetActionTypes?: WidgetActionType[]; } export interface WidgetControllerDescriptor { @@ -510,8 +513,8 @@ export const datasourcesHasOnlyComparisonAggregation = (datasources?: Array { + $datasource: D; entityName: string; deviceName: string; entityId: string; @@ -567,6 +570,26 @@ export interface LegendData { data: Array; } +export enum WidgetHeaderActionButtonType { + basic = 'basic', + raised = 'raised', + stroked = 'stroked', + flat = 'flat', + icon = 'icon', + miniFab = 'miniFab' +} + +export const WidgetHeaderActionButtonTypes = Object.keys(WidgetHeaderActionButtonType) as WidgetHeaderActionButtonType[]; + +export const widgetHeaderActionButtonTypeTranslationMap = new Map([ + [WidgetHeaderActionButtonType.basic, 'widget-config.header-button.button-type-basic'], + [WidgetHeaderActionButtonType.raised, 'widget-config.header-button.button-type-raised'], + [WidgetHeaderActionButtonType.stroked, 'widget-config.header-button.button-type-stroked'], + [WidgetHeaderActionButtonType.flat, 'widget-config.header-button.button-type-flat'], + [WidgetHeaderActionButtonType.icon, 'widget-config.header-button.button-type-icon'], + [WidgetHeaderActionButtonType.miniFab, 'widget-config.header-button.button-type-mini-fab'] +]); + export enum WidgetActionType { doNothing = 'doNothing', openDashboardState = 'openDashboardState', @@ -575,7 +598,8 @@ export enum WidgetActionType { custom = 'custom', customPretty = 'customPretty', mobileAction = 'mobileAction', - openURL = 'openURL' + openURL = 'openURL', + placeMapItem = 'placeMapItem' } export enum WidgetMobileActionType { @@ -586,10 +610,19 @@ export enum WidgetMobileActionType { scanQrCode = 'scanQrCode', makePhoneCall = 'makePhoneCall', getLocation = 'getLocation', - takeScreenshot = 'takeScreenshot' + takeScreenshot = 'takeScreenshot', + deviceProvision = 'deviceProvision', } -export const widgetActionTypes = Object.keys(WidgetActionType) as WidgetActionType[]; +export enum MapItemType { + marker = 'marker', + polygon = 'polygon', + rectangle = 'rectangle', + circle = 'circle' +} + +export const widgetActionTypes = Object.keys(WidgetActionType) + .filter(value => value !== WidgetActionType.placeMapItem) as WidgetActionType[]; export const widgetActionTypeTranslationMap = new Map( [ @@ -600,7 +633,8 @@ export const widgetActionTypeTranslationMap = new Map( [ WidgetActionType.custom, 'widget-action.custom' ], [ WidgetActionType.customPretty, 'widget-action.custom-pretty' ], [ WidgetActionType.mobileAction, 'widget-action.mobile-action' ], - [ WidgetActionType.openURL, 'widget-action.open-URL' ] + [ WidgetActionType.openURL, 'widget-action.open-URL' ], + [ WidgetActionType.placeMapItem, 'widget-action.place-map-item' ], ] ); @@ -613,10 +647,20 @@ export const widgetMobileActionTypeTranslationMap = new Map( + [ + [ MapItemType.marker, 'widget-action.map-item.marker' ], + [ MapItemType.polygon, 'widget-action.map-item.polygon' ], + [ MapItemType.rectangle, 'widget-action.map-item.rectangle' ], + [ MapItemType.circle, 'widget-action.map-item.circle' ], + ] +) + export interface MobileLaunchResult { launched: boolean; } @@ -635,10 +679,15 @@ export interface MobileLocationResult { longitude: number; } +export interface MobileDeviceProvisionResult { + deviceName: string; +} + export type MobileActionResult = MobileLaunchResult & MobileImageResult & MobileQrCodeResult & - MobileLocationResult; + MobileLocationResult & + MobileDeviceProvisionResult; export interface WidgetMobileActionResult { result?: T; @@ -647,6 +696,10 @@ export interface WidgetMobileActionResult { hasError: boolean; } +export interface ProvisionSuccessDescriptor { + handleProvisionSuccessFunction: TbFunction; +} + export interface ProcessImageDescriptor { processImageFunction: TbFunction; } @@ -675,7 +728,8 @@ export type WidgetMobileActionDescriptors = ProcessImageDescriptor & LaunchMapDescriptor & ScanQrCodeDescriptor & MakePhoneCallDescriptor & - GetLocationDescriptor; + GetLocationDescriptor & + ProvisionSuccessDescriptor; export interface WidgetMobileActionDescriptor extends WidgetMobileActionDescriptors { type: WidgetMobileActionType; @@ -692,6 +746,7 @@ export interface CustomActionDescriptor { } export interface WidgetAction extends CustomActionDescriptor { + name?: string; type: WidgetActionType; targetDashboardId?: string; targetDashboardStateId?: string; @@ -713,12 +768,19 @@ export interface WidgetAction extends CustomActionDescriptor { stateEntityParamName?: string; mobileAction?: WidgetMobileActionDescriptor; url?: string; + mapItemType?: MapItemType; } export interface WidgetActionDescriptor extends WidgetAction { id: string; name: string; + buttonType?: WidgetHeaderActionButtonType; + showIcon?: boolean; icon: string; + buttonColor?: string; + buttonFillColor?: string; + buttonBorderColor?: string; + customButtonStyle?: string; displayName?: string; useShowWidgetActionFunction?: boolean; showWidgetActionFunction?: TbFunction; @@ -729,7 +791,13 @@ export const actionDescriptorToAction = (descriptor: WidgetActionDescriptor): Wi const result: WidgetActionDescriptor = {...descriptor}; delete result.id; delete result.name; + delete result.buttonType; + delete result.showIcon; delete result.icon; + delete result.buttonColor; + delete result.buttonFillColor; + delete result.buttonBorderColor; + delete result.customButtonStyle; delete result.displayName; delete result.useShowWidgetActionFunction; delete result.showWidgetActionFunction; @@ -861,6 +929,7 @@ export interface IWidgetSettingsComponent { aliasController: IAliasController; callbacks: WidgetConfigCallbacks; dataKeyCallbacks: DataKeysCallbacks; + functionsOnly: boolean; dashboard: Dashboard; widget: Widget; widgetConfig: WidgetConfigComponentData; @@ -882,6 +951,8 @@ export abstract class WidgetSettingsComponent extends PageComponent implements dataKeyCallbacks: DataKeysCallbacks; + functionsOnly: boolean; + dashboard: Dashboard; widget: Widget; diff --git a/ui-ngx/src/app/shared/models/widget/maps/map-export.models.ts b/ui-ngx/src/app/shared/models/widget/maps/map-export.models.ts new file mode 100644 index 0000000000..f7750db731 --- /dev/null +++ b/ui-ngx/src/app/shared/models/widget/maps/map-export.models.ts @@ -0,0 +1,151 @@ +/// +/// 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 { EntityAliases, EntityAliasInfo, getEntityAliasId } from '@shared/models/alias.models'; +import { FilterInfo, Filters, getFilterId } from '@shared/models/query/query.models'; +import { Dashboard } from '@shared/models/dashboard.models'; +import { DatasourceType, Widget } from '@shared/models/widget.models'; +import { BaseMapSettings, MapDataSourceSettings, MapType } from '@shared/models/widget/maps/map.models'; +import { WidgetExportDefinition } from '@shared/models/widget/widget-export.models'; + +interface ExportDataSourceInfo { + aliases: {[dataLayerIndex: number]: EntityAliasInfo}; + filters: {[dataLayerIndex: number]: FilterInfo}; +} + +interface MapDatasourcesInfo { + trips?: ExportDataSourceInfo; + markers?: ExportDataSourceInfo; + polygons?: ExportDataSourceInfo; + circles?: ExportDataSourceInfo; + additionalDataSources?: ExportDataSourceInfo; +} + +export const MapExportDefinition: WidgetExportDefinition = { + testWidget(widget: Widget): boolean { + if (widget?.config?.settings) { + const settings = widget.config.settings; + if (settings.mapType && [MapType.image, MapType.geoMap].includes(settings.mapType)) { + if (settings.trips && Array.isArray(settings.trips)) { + return true; + } + if (settings.markers && Array.isArray(settings.markers)) { + return true; + } + if (settings.polygons && Array.isArray(settings.polygons)) { + return true; + } + if (settings.circles && Array.isArray(settings.circles)) { + return true; + } + } + } + return false; + }, + prepareExportInfo(dashboard: Dashboard, widget: Widget): MapDatasourcesInfo { + const settings: BaseMapSettings = widget.config.settings as BaseMapSettings; + const info: MapDatasourcesInfo = {}; + if (settings.trips?.length) { + info.trips = prepareExportDataSourcesInfo(dashboard, settings.trips); + } + if (settings.markers?.length) { + info.markers = prepareExportDataSourcesInfo(dashboard, settings.markers); + } + if (settings.polygons?.length) { + info.polygons = prepareExportDataSourcesInfo(dashboard, settings.polygons); + } + if (settings.circles?.length) { + info.circles = prepareExportDataSourcesInfo(dashboard, settings.circles); + } + if (settings.additionalDataSources?.length) { + info.additionalDataSources = prepareExportDataSourcesInfo(dashboard, settings.additionalDataSources); + } + return info; + }, + updateFromExportInfo(widget: Widget, entityAliases: EntityAliases, filters: Filters, info: MapDatasourcesInfo): void { + const settings: BaseMapSettings = widget.config.settings as BaseMapSettings; + if (info?.trips) { + updateMapDatasourceFromExportInfo(entityAliases, filters, settings.trips, info.trips); + } + if (info?.markers) { + updateMapDatasourceFromExportInfo(entityAliases, filters, settings.markers, info.markers); + } + if (info?.polygons) { + updateMapDatasourceFromExportInfo(entityAliases, filters, settings.polygons, info.polygons); + } + if (info?.circles) { + updateMapDatasourceFromExportInfo(entityAliases, filters, settings.circles, info.circles); + } + if (info?.additionalDataSources) { + updateMapDatasourceFromExportInfo(entityAliases, filters, settings.additionalDataSources, info.additionalDataSources); + } + } +}; + +const updateMapDatasourceFromExportInfo = (entityAliases: EntityAliases, + filters: Filters, settings: MapDataSourceSettings[], info: ExportDataSourceInfo): void => { + if (info.aliases) { + for (const dsIndexStr of Object.keys(info.aliases)) { + const dsIndex = Number(dsIndexStr); + if (settings[dsIndex] && settings[dsIndex].dsType === DatasourceType.entity) { + const aliasInfo = info.aliases[dsIndex]; + settings[dsIndex].dsEntityAliasId = getEntityAliasId(entityAliases, aliasInfo); + } + } + } + if (info.filters) { + for (const dsIndexStr of Object.keys(info.filters)) { + const dsIndex = Number(dsIndexStr); + if (settings[dsIndex] && settings[dsIndex].dsType === DatasourceType.entity) { + const filterInfo = info.filters[dsIndex]; + settings[dsIndex].dsFilterId = getFilterId(filters, filterInfo); + } + } + } +} + +const prepareExportDataSourcesInfo = (dashboard: Dashboard, settings: MapDataSourceSettings[]): ExportDataSourceInfo => { + const info: ExportDataSourceInfo = { + aliases: {}, + filters: {} + }; + settings.forEach((dsSettings, index) => { + prepareExportDataSourceInfo(dashboard, info, dsSettings, index); + }); + return info; +} + +const prepareExportDataSourceInfo = (dashboard: Dashboard, info: ExportDataSourceInfo, settings: MapDataSourceSettings, index: number): void => { + if (settings.dsType === DatasourceType.entity) { + const entityAlias = dashboard.configuration.entityAliases[settings.dsEntityAliasId]; + if (entityAlias) { + info.aliases[index] = { + alias: entityAlias.alias, + filter: entityAlias.filter + }; + } + if (settings.dsFilterId && dashboard.configuration.filters) { + const filter = dashboard.configuration.filters[settings.dsFilterId]; + if (filter) { + info.filters[index] = { + filter: filter.filter, + keyFilters: filter.keyFilters, + editable: filter.editable + }; + } + } + } +} 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 new file mode 100644 index 0000000000..1884e86215 --- /dev/null +++ b/ui-ngx/src/app/shared/models/widget/maps/map.models.ts @@ -0,0 +1,1368 @@ +/// +/// 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 { + DataKey, + Datasource, + DatasourceType, + FormattedData, + WidgetAction, + WidgetActionType +} from '@shared/models/widget.models'; +import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { + guid, + hashCode, + isDefinedAndNotNull, + isNotEmptyStr, + isNumber, + isString, + isUndefinedOrNull, + mergeDeep +} from '@core/utils'; +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; +import { materialColors } from '@shared/models/material.models'; +import type L from 'leaflet'; +import { TbFunction } from '@shared/models/js-function.models'; +import { Observable, Observer, of, switchMap } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { MarkerIconContainer, MarkerShape } from '@shared/models/widget/maps/marker-shape.models'; +import { ColorRange, DateFormatSettings, simpleDateFormat } from '@shared/models/widget-settings.models'; + +export enum MapType { + geoMap = 'geoMap', + image = 'image' +} + +export interface MapDataSourceSettings { + dsType: DatasourceType; + dsLabel?: string; + dsDeviceId?: string; + dsEntityAliasId?: string; + dsFilterId?: string; +} + +export interface TbMapDatasource extends Datasource { + mapDataIds: string[]; +} + +export const mapDataSourceSettingsToDatasource = (settings: MapDataSourceSettings): TbMapDatasource => { + return { + type: settings.dsType, + name: settings.dsLabel, + deviceId: settings.dsDeviceId, + entityAliasId: settings.dsEntityAliasId, + filterId: settings.dsFilterId, + dataKeys: [], + mapDataIds: [guid()] + }; +}; + + +export enum DataLayerPatternType { + pattern = 'pattern', + function = 'function' +} + +export interface DataLayerPatternSettings { + show: boolean; + type: DataLayerPatternType; + pattern?: string; + patternFunction?: TbFunction; +} + +export enum DataLayerTooltipTrigger { + click = 'click', + hover = 'hover' +} + +export const dataLayerTooltipTriggers = Object.keys(DataLayerTooltipTrigger) as DataLayerTooltipTrigger[]; + +export const dataLayerTooltipTriggerTranslationMap = new Map( + [ + [DataLayerTooltipTrigger.click, 'widgets.maps.data-layer.tooltip-trigger-click'], + [DataLayerTooltipTrigger.hover, 'widgets.maps.data-layer.tooltip-trigger-hover'] + ] +); + +export interface DataLayerTooltipSettings extends DataLayerPatternSettings { + trigger: DataLayerTooltipTrigger; + autoclose: boolean; + offsetX: number; + offsetY: number; + tagActions?: WidgetAction[]; +} + +export enum DataLayerEditAction { + add = 'add', + edit = 'edit', + move = 'move', + remove = 'remove' +} + +export const dataLayerEditActions = Object.keys(DataLayerEditAction) as DataLayerEditAction[]; + +export const dataLayerEditActionTranslationMap = new Map( + [ + [DataLayerEditAction.add, 'widgets.maps.data-layer.action-add'], + [DataLayerEditAction.edit, 'widgets.maps.data-layer.action-edit'], + [DataLayerEditAction.move, 'widgets.maps.data-layer.action-move'], + [DataLayerEditAction.remove, 'widgets.maps.data-layer.action-remove'] + ] +); + +export interface DataLayerEditSettings { + enabledActions: DataLayerEditAction[]; + attributeScope: AttributeScope; + snappable: boolean; +} + +export interface MapDataLayerSettings extends MapDataSourceSettings { + additionalDataKeys?: DataKey[]; + label: DataLayerPatternSettings; + tooltip: DataLayerTooltipSettings; + click: WidgetAction; + groups?: string[]; + edit: DataLayerEditSettings; +} + +export const defaultBaseDataLayerSettings = (mapType: MapType): Partial => ({ + label: { + show: true, + type: DataLayerPatternType.pattern, + pattern: '${entityName}' + }, + tooltip: { + show: true, + trigger: DataLayerTooltipTrigger.click, + autoclose: true, + type: DataLayerPatternType.pattern, + pattern: mapType === MapType.geoMap ? + '${entityName}

    Latitude: ${latitude:7}
    Longitude: ${longitude:7}
    Temperature: ${temperature} °C
    See tooltip settings for details' + : '${entityName}

    X Pos: ${xPos:2}
    Y Pos: ${yPos:2}
    Temperature: ${temperature} °C
    See tooltip settings for details', + offsetX: 0, + offsetY: -1 + }, + click: { + type: WidgetActionType.doNothing + }, + edit: { + enabledActions: [], + attributeScope: AttributeScope.SERVER_SCOPE, + snappable: false + } +}) + +export type MapDataLayerType = 'trips' | 'markers' | 'polygons' | 'circles'; + +export const mapDataLayerTypes: MapDataLayerType[] = ['trips', 'markers', 'polygons', 'circles']; + +export const mapDataLayerValid = (dataLayer: MapDataLayerSettings, type: MapDataLayerType): boolean => { + if (!dataLayer.dsType || ![DatasourceType.function, DatasourceType.device, DatasourceType.entity].includes(dataLayer.dsType)) { + return false; + } + switch (dataLayer.dsType) { + case DatasourceType.function: + break; + case DatasourceType.device: + if (!dataLayer.dsDeviceId) { + return false; + } + break; + case DatasourceType.entity: + if (!dataLayer.dsEntityAliasId) { + return false; + } + break; + } + switch (type) { + case 'markers': + const markersDataLayer = dataLayer as MarkersDataLayerSettings; + if (!markersDataLayer.xKey?.type || !markersDataLayer.xKey?.name || + !markersDataLayer.yKey?.type || !markersDataLayer.xKey?.name) { + return false; + } + break; + case 'polygons': + const polygonsDataLayer = dataLayer as PolygonsDataLayerSettings; + if (!polygonsDataLayer.polygonKey?.type || !polygonsDataLayer.polygonKey?.name) { + return false; + } + break; + case 'circles': + const circlesDataLayer = dataLayer as CirclesDataLayerSettings; + if (!circlesDataLayer.circleKey?.type || !circlesDataLayer.circleKey?.name) { + return false; + } + break; + } + return true; +}; + +export const mapDataLayerValidator = (type: MapDataLayerType): ValidatorFn => { + return (control: AbstractControl): ValidationErrors | null => { + const layer: MapDataLayerSettings = control.value; + if (!mapDataLayerValid(layer, type)) { + return { + layer: true + }; + } + return null; + }; +}; + +export enum MarkerType { + shape = 'shape', + icon = 'icon', + image = 'image' +} + +export enum DataLayerColorType { + constant = 'constant', + range = 'range', + function = 'function' +} + +export interface DataLayerColorSettings { + type: DataLayerColorType; + color: string; + rangeKey?: DataKey; + range?: ColorRange[]; + colorFunction?: TbFunction; +} + +export enum MarkerImageType { + image = 'image', + function = 'function' +} + +export interface MarkerImageSettings { + type: MarkerImageType; + image?: string; + imageSize?: number; + imageFunction?: TbFunction; + images?: string[]; +} + +export interface BaseMarkerShapeSettings { + size: number; + color: DataLayerColorSettings; +} + +export interface MarkerShapeSettings extends BaseMarkerShapeSettings { + shape: MarkerShape; +} + +export interface MarkerIconSettings extends BaseMarkerShapeSettings { + iconContainer?: MarkerIconContainer; + icon: string; +} +export interface MarkerClusteringSettings { + enable: boolean; + zoomOnClick: boolean; + maxZoom: number; + maxClusterRadius: number; + zoomAnimation: boolean; + showCoverageOnHover: boolean; + spiderfyOnMaxZoom: boolean; + chunkedLoad: boolean; + lazyLoad: boolean; + useClusterMarkerColorFunction: boolean; + clusterMarkerColorFunction: TbFunction; +} + + +export interface MarkersDataLayerSettings extends MapDataLayerSettings { + xKey: DataKey; + yKey: DataKey; + markerType: MarkerType; + markerShape?: MarkerShapeSettings; + markerIcon?: MarkerIconSettings; + markerImage?: MarkerImageSettings; + markerOffsetX: number; + markerOffsetY: number; + positionFunction?: TbFunction; + markerClustering: MarkerClusteringSettings; +} + +const defaultMarkerLatitudeFunction = 'var value = prevValue || 15.833293;\n' + + 'if (time % 500 < 500) {\n' + + ' value += Math.random() * 0.05 - 0.025;\n' + + '}\n' + + 'return value;'; + +const defaultMarkerLongitudeFunction = 'var value = prevValue || -90.454350;\n' + + 'if (time % 500 < 500) {\n' + + ' value += Math.random() * 0.05 - 0.025;\n' + + '}\n' + + 'return value;'; + +const defaultMarkerXPosFunction = 'var value = prevValue || 0.2;\n' + + 'if (time % 500 < 500) {\n' + + ' value += Math.random() * 0.05 - 0.025;\n' + + '}\n' + + 'return value;'; + +const defaultMarkerYPosFunction = 'var value = prevValue || 0.3;\n' + + 'if (time % 500 < 500) {\n' + + ' value += Math.random() * 0.05 - 0.025;\n' + + '}\n' + + 'return value;'; + +const defaultMarkersDataSourceSettings = (mapType: MapType, timeSeries = false, functionsOnly = false): Partial => ({ + dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, + dsLabel: functionsOnly ? 'First point' : '', + xKey: { + name: functionsOnly ? 'f(x)' : (MapType.geoMap === mapType ? 'latitude' : 'xPos'), + label: MapType.geoMap === mapType ? 'latitude' : 'xPos', + type: functionsOnly ? DataKeyType.function : (timeSeries ? DataKeyType.timeseries : DataKeyType.attribute), + funcBody: functionsOnly ? (MapType.geoMap === mapType ? defaultMarkerLatitudeFunction : defaultMarkerXPosFunction) : undefined, + settings: {}, + color: materialColors[0].value + }, + yKey: { + name: functionsOnly ? 'f(x)' : (MapType.geoMap === mapType ? 'longitude' : 'yPos'), + label: MapType.geoMap === mapType ? 'longitude' : 'yPos', + type: functionsOnly ? DataKeyType.function : (timeSeries ? DataKeyType.timeseries : DataKeyType.attribute), + funcBody: functionsOnly ? (MapType.geoMap === mapType ? defaultMarkerLongitudeFunction : defaultMarkerYPosFunction) : undefined, + settings: {}, + color: materialColors[0].value + } +}); + +export const defaultMarkersDataLayerSettings = (mapType: MapType, functionsOnly = false): MarkersDataLayerSettings => mergeDeep( + defaultMarkersDataSourceSettings(mapType, false, functionsOnly) as MarkersDataLayerSettings, + defaultBaseMarkersDataLayerSettings(mapType) as MarkersDataLayerSettings); + +export const defaultBaseMarkersDataLayerSettings = (mapType: MapType): Partial => mergeDeep({ + markerType: MarkerType.shape, + markerShape: { + shape: MarkerShape.markerShape1, + size: 34, + color: { + type: DataLayerColorType.constant, + color: '#307FE5', + } + }, + markerIcon: { + iconContainer: MarkerIconContainer.iconContainer1, + icon: 'mdi:lightbulb-on', + size: 48, + color: { + type: DataLayerColorType.constant, + color: '#307FE5', + } + }, + markerImage: { + type: MarkerImageType.image, + image: '/assets/markers/shape1.svg', + imageSize: 34 + }, + markerOffsetX: 0.5, + markerOffsetY: 1, + positionFunction: 'return {x: origXPos, y: origYPos};', + markerClustering: { + enable: false, + zoomOnClick: true, + maxZoom: null, + maxClusterRadius: 80, + zoomAnimation: true, + showCoverageOnHover: true, + spiderfyOnMaxZoom: false, + chunkedLoad: false, + lazyLoad: true, + useClusterMarkerColorFunction: false, + clusterMarkerColorFunction: null + } +} as MarkersDataLayerSettings, defaultBaseDataLayerSettings(mapType)); + +export enum PathDecoratorSymbol { + arrowHead = 'arrowHead', + dash = 'dash' +} + +export const pathDecoratorSymbols = Object.keys(PathDecoratorSymbol) as PathDecoratorSymbol[]; + +export const pathDecoratorSymbolTranslationMap = new Map( + [ + [PathDecoratorSymbol.arrowHead, 'widgets.maps.data-layer.path.decorator-symbol-arrow-head'], + [PathDecoratorSymbol.dash, 'widgets.maps.data-layer.path.decorator-symbol-dash'] + ] +); + +export interface TripsDataLayerSettings extends MarkersDataLayerSettings { + showMarker: boolean; + rotateMarker: boolean; + offsetAngle: number; + showPath: boolean; + pathStrokeWeight?: number; + pathStrokeColor?: DataLayerColorSettings; + usePathDecorator?: boolean; + pathDecoratorSymbol?: PathDecoratorSymbol; + pathDecoratorSymbolSize?: number; + pathDecoratorSymbolColor?: string; + pathDecoratorOffset?: number; + pathEndDecoratorOffset?: number; + pathDecoratorRepeat?: number; + showPoints: boolean; + pointSize?: number; + pointColor?: DataLayerColorSettings; + pointTooltip?: DataLayerTooltipSettings; +} + +export const defaultTripsDataLayerSettings = (mapType: MapType, functionsOnly = false): TripsDataLayerSettings => mergeDeep( + defaultMarkersDataSourceSettings(mapType, true, functionsOnly) as TripsDataLayerSettings, + defaultBaseTripsDataLayerSettings(mapType) as TripsDataLayerSettings); + +export const defaultBaseTripsDataLayerSettings = (mapType: MapType): Partial => mergeDeep( + defaultBaseMarkersDataLayerSettings(mapType), + { + showMarker: true, + tooltip: { + offsetY: -0.5, + pattern: mapType === MapType.geoMap ? + '${entityName}

    Latitude: ${latitude:7}
    Longitude: ${longitude:7}
    End Time: ${maxTime}
    Start Time: ${minTime}' + : '${entityName}

    X Pos: ${xPos:2}
    Y Pos: ${yPos:2}
    End Time: ${maxTime}
    Start Time: ${minTime}', + }, + rotateMarker: true, + offsetAngle: 0, + markerShape: { + shape: MarkerShape.tripMarkerShape2 + }, + markerIcon: { + iconContainer: MarkerIconContainer.tripIconContainer1, + icon: 'arrow_forward' + }, + markerImage: { + image: '/assets/markers/tripShape2.svg' + }, + markerOffsetX: 0.5, + markerOffsetY: 0.5, + showPath: true, + pathStrokeWeight: 4, + pathStrokeColor: { + type: DataLayerColorType.constant, + color: '#307FE5', + }, + usePathDecorator: false, + pathDecoratorSymbol: PathDecoratorSymbol.arrowHead, + pathDecoratorSymbolSize: 10, + pathDecoratorSymbolColor: '#307FE5', + pathDecoratorOffset: 20, + pathEndDecoratorOffset: 20, + pathDecoratorRepeat: 20, + showPoints: false, + pointSize: 10, + pointColor: { + type: DataLayerColorType.constant, + color: '#307FE5', + }, + pointTooltip: { + show: true, + trigger: DataLayerTooltipTrigger.click, + autoclose: true, + type: DataLayerPatternType.pattern, + pattern: mapType === MapType.geoMap ? + '${entityName}

    Latitude: ${latitude:7}
    Longitude: ${longitude:7}
    End Time: ${maxTime}
    Start Time: ${minTime}' + : '${entityName}

    X Pos: ${xPos:2}
    Y Pos: ${yPos:2}
    End Time: ${maxTime}
    Start Time: ${minTime}', + offsetX: 0, + offsetY: -1 + }, + } as TripsDataLayerSettings); + +export interface ShapeDataLayerSettings extends MapDataLayerSettings { + fillColor: DataLayerColorSettings; + strokeColor: DataLayerColorSettings; + strokeWeight: number; +} + +export interface PolygonsDataLayerSettings extends ShapeDataLayerSettings { + polygonKey: DataKey; +} + +export const defaultPolygonsDataLayerSettings = (mapType: MapType, functionsOnly = false): PolygonsDataLayerSettings => mergeDeep({ + dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, + dsLabel: functionsOnly ? 'First polygon' : '', + polygonKey: { + name: functionsOnly ? 'f(x)' : 'perimeter', + label: 'perimeter', + type: functionsOnly ? DataKeyType.function : DataKeyType.attribute, + settings: {}, + color: materialColors[0].value + } +} as PolygonsDataLayerSettings, defaultBasePolygonsDataLayerSettings(mapType) as PolygonsDataLayerSettings); + +export const defaultBasePolygonsDataLayerSettings = (mapType: MapType): Partial => mergeDeep({ + fillColor: { + type: DataLayerColorType.constant, + color: 'rgba(51,136,255,0.2)', + }, + strokeColor: { + type: DataLayerColorType.constant, + color: '#3388ff', + }, + strokeWeight: 3 +} as Partial, defaultBaseDataLayerSettings(mapType), + {label: {show: false}, tooltip: {show: false, pattern: '${entityName}

    TimeStamp: ${ts:7}'}} as Partial) + +export interface CirclesDataLayerSettings extends ShapeDataLayerSettings { + circleKey: DataKey; +} + +export const defaultCirclesDataLayerSettings = (mapType: MapType, functionsOnly = false): CirclesDataLayerSettings => mergeDeep({ + dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, + dsLabel: functionsOnly ? 'First circle' : '', + circleKey: { + name: functionsOnly ? 'f(x)' : 'perimeter', + label: 'perimeter', + type: functionsOnly ? DataKeyType.function : DataKeyType.attribute, + settings: {}, + color: materialColors[0].value + } +} as CirclesDataLayerSettings, defaultBaseCirclesDataLayerSettings(mapType) as CirclesDataLayerSettings); + +export const defaultBaseCirclesDataLayerSettings = (mapType: MapType): Partial => mergeDeep({ + fillColor: { + type: DataLayerColorType.constant, + color: 'rgba(51,136,255,0.2)', + }, + strokeColor: { + type: DataLayerColorType.constant, + color: '#3388ff', + }, + strokeWeight: 3 +} as Partial, defaultBaseDataLayerSettings(mapType), + {label: {show: false}, tooltip: {show: false, pattern: '${entityName}

    TimeStamp: ${ts:7}'}} as Partial) + +export const defaultMapDataLayerSettings = (mapType: MapType, dataLayerType: MapDataLayerType, functionsOnly = false): MapDataLayerSettings => { + switch (dataLayerType) { + case 'trips': + return defaultTripsDataLayerSettings(mapType, functionsOnly); + case 'markers': + return defaultMarkersDataLayerSettings(mapType, functionsOnly); + case 'polygons': + return defaultPolygonsDataLayerSettings(mapType, functionsOnly); + case 'circles': + return defaultCirclesDataLayerSettings(mapType, functionsOnly); + } +}; + +export const defaultBaseMapDataLayerSettings = (mapType: MapType, dataLayerType: MapDataLayerType): T => { + switch (dataLayerType) { + case 'trips': + return defaultBaseTripsDataLayerSettings(mapType) as T; + case 'markers': + return defaultBaseMarkersDataLayerSettings(mapType) as T; + case 'polygons': + return defaultBasePolygonsDataLayerSettings(mapType) as T; + case 'circles': + return defaultBaseCirclesDataLayerSettings(mapType) as T; + } +} + +export interface AdditionalMapDataSourceSettings extends MapDataSourceSettings { + dataKeys: DataKey[]; +} + +export const additionalMapDataSourcesToDatasources = (additionalMapDataSources: AdditionalMapDataSourceSettings[]): TbMapDatasource[] => { + return additionalMapDataSources.map(addDs => { + const res = mapDataSourceSettingsToDatasource(addDs); + res.dataKeys = addDs.dataKeys; + return res; + }); +}; + +export const additionalMapDataSourceValid = (dataSource: AdditionalMapDataSourceSettings): boolean => { + if (!dataSource.dsType || ![DatasourceType.function, DatasourceType.device, DatasourceType.entity].includes(dataSource.dsType)) { + return false; + } + return !!dataSource.dataKeys?.length; +}; + +export const additionalMapDataSourceValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => { + const dataSource: AdditionalMapDataSourceSettings = control.value; + if (!additionalMapDataSourceValid(dataSource)) { + return { + dataSource: true + }; + } + return null; +}; + +export const defaultAdditionalMapDataSourceSettings = (functionsOnly = false): AdditionalMapDataSourceSettings => { + return { + dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, + dsLabel: functionsOnly ? 'Additional data' : '', + dataKeys: [] + }; +}; + +export enum MapControlsPosition { + topleft = 'topleft', + topright = 'topright', + bottomleft = 'bottomleft', + bottomright = 'bottomright' +} + +export const mapControlPositions = Object.keys(MapControlsPosition) as MapControlsPosition[]; + +export const mapControlsPositionTranslationMap = new Map( + [ + [MapControlsPosition.topleft, 'widgets.maps.control.position-topleft'], + [MapControlsPosition.topright, 'widgets.maps.control.position-topright'], + [MapControlsPosition.bottomleft, 'widgets.maps.control.position-bottomleft'], + [MapControlsPosition.bottomright, 'widgets.maps.control.position-bottomright'] + ] +); + +export enum MapZoomAction { + scroll = 'scroll', + doubleClick = 'doubleClick', + controlButtons = 'controlButtons' +} + +export const mapZoomActions = Object.keys(MapZoomAction) as MapZoomAction[]; + +export const mapZoomActionTranslationMap = new Map( + [ + [MapZoomAction.scroll, 'widgets.maps.control.zoom-scroll'], + [MapZoomAction.doubleClick, 'widgets.maps.control.zoom-double-click'], + [MapZoomAction.controlButtons, 'widgets.maps.control.zoom-control-buttons'] + ] +); + +export enum MapScale { + metric = 'metric', + imperial = 'imperial' +} + +export const mapScales = Object.keys(MapScale) as MapScale[]; + +export const mapScaleTranslationMap = new Map( + [ + [MapScale.metric, 'widgets.maps.control.scale-metric'], + [MapScale.imperial, 'widgets.maps.control.scale-imperial'], + ] +); + +export interface MapActionButtonSettings { + label?: string; + icon?: string; + color: string; + action: WidgetAction; +} + +export interface TripTimelineSettings { + showTimelineControl: boolean; + timeStep: number; + speedOptions: number[]; + showTimestamp: boolean; + timestampFormat: DateFormatSettings; + snapToRealLocation: boolean; + locationSnapFilter: TbFunction; +} + +export interface BaseMapSettings { + mapType: MapType; + trips?: TripsDataLayerSettings[]; + markers: MarkersDataLayerSettings[]; + polygons: PolygonsDataLayerSettings[]; + circles: CirclesDataLayerSettings[]; + additionalDataSources: AdditionalMapDataSourceSettings[]; + controlsPosition: MapControlsPosition; + zoomActions: MapZoomAction[]; + scales: MapScale[]; + dragModeButton: boolean; + fitMapBounds: boolean; + useDefaultCenterPosition: boolean; + defaultCenterPosition?: string; + defaultZoomLevel: number; + minZoomLevel: number; + mapPageSize: number; + mapActionButtons: MapActionButtonSettings[]; + tripTimeline?: TripTimelineSettings; +} + +export const DEFAULT_MAP_PAGE_SIZE = 16384; +export const DEFAULT_ZOOM_LEVEL = 8; + +export const defaultBaseMapSettings: BaseMapSettings = { + mapType: MapType.geoMap, + trips: [], + markers: [], + polygons: [], + circles: [], + additionalDataSources: [], + controlsPosition: MapControlsPosition.topleft, + zoomActions: [MapZoomAction.scroll, MapZoomAction.doubleClick, MapZoomAction.controlButtons], + scales: [], + dragModeButton: false, + fitMapBounds: true, + useDefaultCenterPosition: false, + defaultCenterPosition: '0,0', + defaultZoomLevel: null, + minZoomLevel: 16, + mapPageSize: DEFAULT_MAP_PAGE_SIZE, + mapActionButtons: [], + tripTimeline: { + showTimelineControl: false, + timeStep: 1000, + speedOptions: [1,5,10,15,25], + showTimestamp: true, + timestampFormat: simpleDateFormat('yyyy-MM-dd HH:mm:ss'), + snapToRealLocation: false, + locationSnapFilter: 'return true;' + } +}; + +export const defaultMapActionButtonSettings: MapActionButtonSettings = { + label: '', + icon: 'add', + color: '#0000008a', + action: { + type: WidgetActionType.doNothing + } +} + +export enum MapProvider { + openstreet = 'openstreet', + google = 'google', + here = 'here', + tencent = 'tencent', + custom = 'custom' +} + +export const mapProviders = Object.keys(MapProvider) as MapProvider[]; + +export const mapProviderTranslationMap = new Map( + [ + [MapProvider.openstreet, 'widgets.maps.layer.provider.openstreet.title'], + [MapProvider.google, 'widgets.maps.layer.provider.google.title'], + [MapProvider.here, 'widgets.maps.layer.provider.here.title'], + [MapProvider.tencent, 'widgets.maps.layer.provider.tencent.title'], + [MapProvider.custom, 'widgets.maps.layer.provider.custom.title'] + ] +); + +export enum ReferenceLayerType { + openstreetmap_hybrid = 'openstreetmap_hybrid', + world_edition_hybrid = 'world_edition_hybrid', + enhanced_contrast_hybrid = 'enhanced_contrast_hybrid' +} + +export const referenceLayerTypes = Object.keys(ReferenceLayerType) as ReferenceLayerType[]; + +export const referenceLayerTypeTranslationMap = new Map( + [ + [ReferenceLayerType.openstreetmap_hybrid, 'widgets.maps.layer.reference.openstreetmap-hybrid'], + [ReferenceLayerType.world_edition_hybrid, 'widgets.maps.layer.reference.world-edition-hybrid'], + [ReferenceLayerType.enhanced_contrast_hybrid, 'widgets.maps.layer.reference.enhanced-contrast-hybrid'] + ] +); + +export interface MapLayerSettings { + label?: string; + provider: MapProvider; + referenceLayer?: ReferenceLayerType; +} + +export const mapLayerValid = (layer: MapLayerSettings): boolean => { + if (!layer.provider) { + return false; + } + switch (layer.provider) { + case MapProvider.openstreet: + const openStreetLayer = layer as OpenStreetMapLayerSettings; + return !!openStreetLayer.layerType; + case MapProvider.google: + const googleLayer = layer as GoogleMapLayerSettings; + return !!googleLayer.layerType; + case MapProvider.here: + const hereLayer = layer as HereMapLayerSettings; + return !!hereLayer.layerType; + case MapProvider.tencent: + const tencentLayer = layer as TencentMapLayerSettings; + return !!tencentLayer.layerType; + case MapProvider.custom: + const customLayer = layer as CustomMapLayerSettings; + return !!customLayer.tileUrl; + } +}; + +export const mapLayerValidator = (control: AbstractControl): ValidationErrors | null => { + const layer: MapLayerSettings = control.value; + if (!mapLayerValid(layer)) { + return { + layer: true + }; + } + return null; +}; + +export const defaultLayerTitle = (layer: MapLayerSettings): string => { + if (!layer.provider) { + return null; + } + switch (layer.provider) { + case MapProvider.openstreet: + const openStreetLayer = layer as OpenStreetMapLayerSettings; + return openStreetMapLayerTranslationMap.get(openStreetLayer.layerType); + case MapProvider.google: + const googleLayer = layer as GoogleMapLayerSettings; + return googleMapLayerTranslationMap.get(googleLayer.layerType); + case MapProvider.here: + const hereLayer = layer as HereMapLayerSettings; + return hereLayerTranslationMap.get(hereLayer.layerType); + case MapProvider.tencent: + const tencentLayer = layer as TencentMapLayerSettings; + return tencentLayerTranslationMap.get(tencentLayer.layerType); + case MapProvider.custom: + return 'widgets.maps.layer.provider.custom.title'; + } +} + +export enum OpenStreetLayerType { + openStreetMapnik = 'OpenStreetMap.Mapnik', + openStreetHot = 'OpenStreetMap.HOT', + esriWorldStreetMap = 'Esri.WorldStreetMap', + esriWorldTopoMap = 'Esri.WorldTopoMap', + esriWorldImagery = 'Esri.WorldImagery', + cartoDbPositron = 'CartoDB.Positron', + cartoDbDarkMatter = 'CartoDB.DarkMatter' +} + +export const openStreetLayerTypes = Object.values(OpenStreetLayerType) as OpenStreetLayerType[]; + +export const openStreetMapLayerTranslationMap = new Map( + [ + [OpenStreetLayerType.openStreetMapnik, 'widgets.maps.layer.provider.openstreet.mapnik'], + [OpenStreetLayerType.openStreetHot, 'widgets.maps.layer.provider.openstreet.hot'], + [OpenStreetLayerType.esriWorldStreetMap, 'widgets.maps.layer.provider.openstreet.esri-street'], + [OpenStreetLayerType.esriWorldTopoMap, 'widgets.maps.layer.provider.openstreet.esri-topo'], + [OpenStreetLayerType.esriWorldImagery, 'widgets.maps.layer.provider.openstreet.esri-imagery'], + [OpenStreetLayerType.cartoDbPositron, 'widgets.maps.layer.provider.openstreet.cartodb-positron'], + [OpenStreetLayerType.cartoDbDarkMatter, 'widgets.maps.layer.provider.openstreet.cartodb-dark-matter'] + ] +); + +export interface OpenStreetMapLayerSettings extends MapLayerSettings { + provider: MapProvider.openstreet; + layerType: OpenStreetLayerType; +} + +export const defaultOpenStreetMapLayerSettings: OpenStreetMapLayerSettings = { + provider: MapProvider.openstreet, + layerType: OpenStreetLayerType.openStreetMapnik +} + +export enum GoogleLayerType { + roadmap = 'roadmap', + satellite = 'satellite', + hybrid = 'hybrid', + terrain = 'terrain' +} + +export const googleMapLayerTypes = Object.values(GoogleLayerType) as GoogleLayerType[]; + +export const googleMapLayerTranslationMap = new Map( + [ + [GoogleLayerType.roadmap, 'widgets.maps.layer.provider.google.roadmap'], + [GoogleLayerType.satellite, 'widgets.maps.layer.provider.google.satellite'], + [GoogleLayerType.hybrid, 'widgets.maps.layer.provider.google.hybrid'], + [GoogleLayerType.terrain, 'widgets.maps.layer.provider.google.terrain'] + ] +); + +export interface GoogleMapLayerSettings extends MapLayerSettings { + provider: MapProvider.google; + layerType: GoogleLayerType; + apiKey: string; +} + +export const defaultGoogleMapLayerSettings: GoogleMapLayerSettings = { + provider: MapProvider.google, + layerType: GoogleLayerType.roadmap, + apiKey: 'AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q' +}; + +export enum HereLayerType { + hereNormalDay = 'HEREv3.normalDay', + hereNormalNight = 'HEREv3.normalNight', + hereHybridDay = 'HEREv3.hybridDay', + hereTerrainDay = 'HEREv3.terrainDay' +} + +export const hereLayerTypes = Object.values(HereLayerType) as HereLayerType[]; + +export const hereLayerTranslationMap = new Map( + [ + [HereLayerType.hereNormalDay, 'widgets.maps.layer.provider.here.normal-day'], + [HereLayerType.hereNormalNight, 'widgets.maps.layer.provider.here.normal-night'], + [HereLayerType.hereHybridDay, 'widgets.maps.layer.provider.here.hybrid-day'], + [HereLayerType.hereTerrainDay, 'widgets.maps.layer.provider.here.terrain-day'] + ] +); + +export interface HereMapLayerSettings extends MapLayerSettings { + provider: MapProvider.here; + layerType: HereLayerType; + apiKey: string; +} + +export const defaultHereMapLayerSettings: HereMapLayerSettings = { + provider: MapProvider.here, + layerType: HereLayerType.hereNormalDay, + apiKey: 'kVXykxAfZ6LS4EbCTO02soFVfjA7HoBzNVVH9u7nzoE' +} + +export enum TencentLayerType { + tencentNormal = 'Tencent.Normal', + tencentSatellite = 'Tencent.Satellite', + tencentTerrain = 'Tencent.Terrain' +} + +export const tencentLayerTypes = Object.values(TencentLayerType) as TencentLayerType[]; + +export const tencentLayerTranslationMap = new Map( + [ + [TencentLayerType.tencentNormal, 'widgets.maps.layer.provider.tencent.normal'], + [TencentLayerType.tencentSatellite, 'widgets.maps.layer.provider.tencent.satellite'], + [TencentLayerType.tencentTerrain, 'widgets.maps.layer.provider.tencent.terrain'] + ] +); + +export interface TencentMapLayerSettings extends MapLayerSettings { + provider: MapProvider.tencent; + layerType: TencentLayerType +} + +export const defaultTencentMapLayerSettings: TencentMapLayerSettings = { + provider: MapProvider.tencent, + layerType: TencentLayerType.tencentNormal +} + +export interface CustomMapLayerSettings extends MapLayerSettings { + provider: MapProvider.custom; + tileUrl: string; +} + +export const defaultCustomMapLayerSettings: CustomMapLayerSettings = { + provider: MapProvider.custom, + tileUrl: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' +} + +export const defaultMapLayerSettings = (provider: MapProvider): MapLayerSettings => { + switch (provider) { + case MapProvider.openstreet: + return defaultOpenStreetMapLayerSettings; + case MapProvider.google: + return defaultGoogleMapLayerSettings; + case MapProvider.here: + return defaultHereMapLayerSettings; + case MapProvider.tencent: + return defaultTencentMapLayerSettings; + case MapProvider.custom: + return defaultCustomMapLayerSettings; + } +}; + +export const defaultMapLayers: MapLayerSettings[] = [ + { + label: '{i18n:widgets.maps.layer.roadmap}', + provider: MapProvider.openstreet, + layerType: OpenStreetLayerType.openStreetMapnik, + } as OpenStreetMapLayerSettings, + { + label: '{i18n:widgets.maps.layer.satellite}', + provider: MapProvider.openstreet, + layerType: OpenStreetLayerType.esriWorldImagery, + } as OpenStreetMapLayerSettings, + { + label: '{i18n:widgets.maps.layer.hybrid}', + provider: MapProvider.openstreet, + layerType: OpenStreetLayerType.esriWorldImagery, + referenceLayer: ReferenceLayerType.openstreetmap_hybrid + } as OpenStreetMapLayerSettings +]; + +export interface GeoMapSettings extends BaseMapSettings { + layers?: MapLayerSettings[]; +} + +export const defaultGeoMapSettings: GeoMapSettings = { + mapType: MapType.geoMap, + layers: mergeDeep([], defaultMapLayers), + ...mergeDeep({} as BaseMapSettings, defaultBaseMapSettings) +}; + +export enum ImageSourceType { + image = 'image', + entityKey = 'entityKey' +} + +export interface ImageMapSourceSettings { + sourceType: ImageSourceType; + url?: string; + entityAliasId?: string; + entityKey?: DataKey; +} + +export const imageMapSourceSettingsValid = (imageSource: ImageMapSourceSettings): boolean => { + if (!imageSource?.sourceType) { + return false; + } else if (imageSource.sourceType === ImageSourceType.image) { + return isNotEmptyStr(imageSource.url); + } else { + return isNotEmptyStr(imageSource.entityAliasId) && !!imageSource.entityKey; + } +} + +export const imageMapSourceSettingsValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => { + const imageSource: ImageMapSourceSettings = control.value; + if (!imageMapSourceSettingsValid(imageSource)) { + return { + imageMapSource: true + }; + } + return null; +}; + +export const defaultImageMapSourceSettings: ImageMapSourceSettings = { + sourceType: ImageSourceType.image, + url: 'data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==', +}; + +export const imageMapSourceSettingsToDatasource = (settings: ImageMapSourceSettings): Datasource => { + return { + type: DatasourceType.entity, + name: '', + entityAliasId: settings.entityAliasId, + dataKeys: [settings.entityKey] + }; +}; + +export interface ImageMapSettings extends BaseMapSettings { + imageSource?: ImageMapSourceSettings; +} + +export const defaultImageMapSettings: ImageMapSettings = { + mapType: MapType.image, + imageSource: mergeDeep({} as ImageMapSourceSettings, defaultImageMapSourceSettings), + ...mergeDeep({} as BaseMapSettings, defaultBaseMapSettings) +} + +export type MapSetting = GeoMapSettings & ImageMapSettings; + +export const defaultMapSettings: MapSetting = defaultGeoMapSettings; + +export interface MarkerImageInfo { + url: string; + size: number; + markerOffset?: [number, number]; + tooltipOffset?: [number, number]; +} + +export interface MarkerIconInfo { + icon: L.Icon; + size: [number, number]; +} + +export type MapStringFunction = (data: FormattedData, + dsData: FormattedData[]) => string; + +export type MapBooleanFunction = (data: FormattedData, + dsData: FormattedData[]) => boolean; + +export type MarkerImageFunction = (data: FormattedData, markerImages: string[], + dsData: FormattedData[]) => MarkerImageInfo; + +export type ClusterMarkerColorFunction = (data: FormattedData[], childCount: number) => string; + +export type MarkerPositionFunction = (origXPos: number, origYPos: number, data: FormattedData, + dsData: FormattedData[], aspect: number) => { x: number, y: number }; + +export type TbPolygonRawCoordinate = L.LatLngTuple | L.LatLngTuple[] | L.LatLngTuple[][]; +export type TbPolygonRawCoordinates = TbPolygonRawCoordinate[]; +export type TbPolyData = L.LatLngTuple[] | L.LatLngTuple[][] | L.LatLngTuple[][][]; +export type TbPolygonCoordinate = L.LatLng | L.LatLng[] | L.LatLng[][]; +export type TbPolygonCoordinates = TbPolygonCoordinate[]; + +export interface TbCircleData { + latitude: number; + longitude: number; + radius: number; +} + +export type DataKeyValuePair = { + dataKey: DataKey; + value: any; +} + +export const isJSON = (data: string): boolean => { + try { + const parseData = JSON.parse(data); + return !Array.isArray(parseData); + } catch (e) { + return false; + } +} + +export const isValidLatitude = (latitude: any): boolean => + isDefinedAndNotNull(latitude) && + !isString(latitude) && + !isNaN(latitude) && isFinite(latitude) && Math.abs(latitude) <= 90; + +export const isValidLongitude = (longitude: any): boolean => + isDefinedAndNotNull(longitude) && + !isString(longitude) && + !isNaN(longitude) && isFinite(longitude) && Math.abs(longitude) <= 180; + +export const isValidLatLng = (latitude: any, longitude: any): boolean => + isValidLatitude(latitude) && isValidLongitude(longitude); + +export const isCutPolygon = (data: TbPolygonCoordinates | TbPolygonRawCoordinates): boolean => { + return data.length > 1 && Array.isArray(data[0]) && (Array.isArray(data[0][0]) || (isNumber((data[0][0] as any).lat) && isNumber((data[0][0] as any).lng)) ); +} + +export const parseCenterPosition = (position: string | [number, number]): [number, number] => { + if (typeof (position) === 'string') { + const parts = position.split(','); + if (parts.length === 2) { + return [Number(parts[0]), Number(parts[1])]; + } + } + if (typeof (position) === 'object') { + return position; + } + return [0, 0]; +} + +export const mergeMapDatasources = (target: TbMapDatasource[], source: TbMapDatasource[]): TbMapDatasource[] => { + const appendDatasources: TbMapDatasource[] = []; + for (const sourceDs of source) { + let merged = false; + for (let i = 0; i < target.length; i++) { + const targetDs = target[i]; + if (mapDatasourceIsSame(targetDs, sourceDs)) { + target[i] = mergeMapDatasource(targetDs, sourceDs); + merged = true; + break; + } + } + if (!merged) { + appendDatasources.push(sourceDs); + } + } + target.push(...appendDatasources); + return target; +}; + +const mapDatasourceIsSame = (ds1: TbMapDatasource, ds2: TbMapDatasource): boolean => { + if (ds1.type === ds2.type) { + switch (ds1.type) { + case DatasourceType.function: + return ds1.name === ds2.name; + case DatasourceType.device: + case DatasourceType.entity: + if (ds1.filterId === ds2.filterId) { + if (ds1.type === DatasourceType.device) { + return ds1.deviceId === ds2.deviceId; + } else { + return ds1.entityAliasId === ds2.entityAliasId; + } + } + } + } + return false; +} + +const mergeMapDatasource = (target: TbMapDatasource, source: TbMapDatasource): TbMapDatasource => { + target.mapDataIds.push(...source.mapDataIds); + const appendKeys: DataKey[] = []; + for (const sourceKey of source.dataKeys) { + const found = + target.dataKeys.find(key => key.type === sourceKey.type && key.name === sourceKey.name && key.label === sourceKey.label); + if (!found) { + appendKeys.push(sourceKey); + } + } + target.dataKeys.push(...appendKeys); + return target; +} + +const imageAspectMap: {[key: string]: ImageWithAspect} = {}; + +const imageLoader = (imageUrl: string): Observable => new Observable((observer: Observer) => { + const image = document.createElement('img'); // support IE + image.style.position = 'absolute'; + image.style.left = '-99999px'; + image.style.top = '-99999px'; + image.onload = () => { + observer.next(image); + document.body.removeChild(image); + observer.complete(); + }; + image.onerror = err => { + observer.error(err); + document.body.removeChild(image); + observer.complete(); + }; + document.body.appendChild(image); + image.src = imageUrl; +}); + +const loadImageAspect = (imageUrl: string): Observable => + imageLoader(imageUrl).pipe(map(image => image.width / image.height)); + +export interface ImageWithAspect { + url: string; + aspect: number; +} + +export const loadImageWithAspect = (imagePipe: ImagePipe, imageUrl: string): Observable => { + if (imageUrl?.length) { + const hash = hashCode(imageUrl); + let imageWithAspect = imageAspectMap[hash]; + if (imageWithAspect) { + return of(imageWithAspect); + } else { + 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}; + imageAspectMap[hash] = imageWithAspect; + return imageWithAspect; + }) + ); + }) + ); + } + } else { + return of(null); + } +}; + +const linkActionRegex = /([^<]*)<\/link-act>/g; +const buttonActionRegex = /([^<]*)<\/button-act>/g; + +const createTooltipLinkElement = (actionName: string, actionText: string): string => { + return `
    ${actionText}`; +} + +const creatTooltipButtonElement = (actionName: string, actionText: string): string => { + return ``; +} + +export const processTooltipTemplate = (template: string): string => { + let actionTags: string; + let actionText: string; + let actionName: string; + let action: string; + + let match = linkActionRegex.exec(template); + while (match !== null) { + [actionTags, actionName, actionText] = match; + action = createTooltipLinkElement(actionName, actionText); + template = template.replace(actionTags, action); + match = linkActionRegex.exec(template); + } + + match = buttonActionRegex.exec(template); + while (match !== null) { + [actionTags, actionName, actionText] = match; + action = creatTooltipButtonElement(actionName, actionText); + template = template.replace(actionTags, action); + match = buttonActionRegex.exec(template); + } + + return template; +} + +export const calculateNewPointCoordinate = (coordinate: number, imageSize: number): number => { + let pointCoordinate = coordinate / imageSize; + if (pointCoordinate < 0) { + pointCoordinate = 0; + } else if (pointCoordinate > 1) { + pointCoordinate = 1; + } + return pointCoordinate; +} + +export const latLngPointToBounds = (point: L.LatLng, southWest: L.LatLng, northEast: L.LatLng, offset = 0): L.LatLng => { + const maxLngMap = northEast.lng - offset; + const minLngMap = southWest.lng + offset; + const maxLatMap = northEast.lat - offset; + const minLatMap = southWest.lat + offset; + if (point.lng > maxLngMap) { + point.lng = maxLngMap; + } else if (point.lng < minLngMap) { + point.lng = minLngMap; + } + if (point.lat > maxLatMap) { + point.lat = maxLatMap; + } else if (point.lat < minLatMap) { + point.lat = minLatMap; + } + return point; +} + +export type TripRouteData = {[time: number]: FormattedData}; + +export const calculateInterpolationRatio = (firsMoment: number, secondMoment: number, intermediateMoment: number): number => { + return (intermediateMoment - firsMoment) / (secondMoment - firsMoment); +} + +export const interpolateLineSegment = ( + pointA: FormattedData, + pointB: FormattedData, + xKey: string, + yKey: string, + ratio: number +): { [key: string]: number } => { + return { + [xKey]: (pointA[xKey] + (pointB[xKey] - pointA[xKey]) * ratio), + [yKey]: (pointA[yKey] + (pointB[yKey] - pointA[yKey]) * ratio) + }; +} + +export const findRotationAngle = (startPoint: L.LatLng, endPoint: L.LatLng): number => { + if (isUndefinedOrNull(startPoint) || isUndefinedOrNull(endPoint)) { + return 0; + } + let angle = -Math.atan2(endPoint.lat - startPoint.lat, endPoint.lng - startPoint.lng); + angle = angle * 180 / Math.PI; + return parseInt(angle.toFixed(2), 10); +} + +export const calculateLastPoints = (routeData: TripRouteData, time: number): FormattedData => { + const timeArr = Object.keys(routeData); + let index = timeArr.findIndex((dtime) => { + return Number(dtime) >= time; + }); + + if (index !== -1) { + if (Number(timeArr[index]) !== time && index !== 0) { + index--; + } + } else { + index = timeArr.length - 1; + } + + return routeData[timeArr[index]]; +} diff --git a/ui-ngx/src/app/shared/models/widget/maps/marker-shape.models.ts b/ui-ngx/src/app/shared/models/widget/maps/marker-shape.models.ts new file mode 100644 index 0000000000..f0783a4ac9 --- /dev/null +++ b/ui-ngx/src/app/shared/models/widget/maps/marker-shape.models.ts @@ -0,0 +1,361 @@ +/// +/// 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 tinycolor from 'tinycolor2'; +import { MatIconRegistry } from '@angular/material/icon'; +import { DomSanitizer } from '@angular/platform-browser'; +import { Observable, of, shareReplay, switchMap } from 'rxjs'; +import { catchError, map, take } from 'rxjs/operators'; +import { isSvgIcon, splitIconName } from '@shared/models/icon.models'; +import { Element, G, SVG, Text } from '@svgdotjs/svg.js'; +import { guid } from '@core/utils'; + +export enum MarkerShape { + markerShape1 = 'markerShape1', + markerShape2 = 'markerShape2', + markerShape3 = 'markerShape3', + markerShape4 = 'markerShape4', + markerShape5 = 'markerShape5', + markerShape6 = 'markerShape6', + markerShape7 = 'markerShape7', + markerShape8 = 'markerShape8', + markerShape9 = 'markerShape9', + markerShape10 = 'markerShape10', + tripMarkerShape1 = 'tripMarkerShape1', + tripMarkerShape2 = 'tripMarkerShape2', + tripMarkerShape3 = 'tripMarkerShape3', + tripMarkerShape4 = 'tripMarkerShape4', + tripMarkerShape5 = 'tripMarkerShape5', + tripMarkerShape6 = 'tripMarkerShape6', + tripMarkerShape7 = 'tripMarkerShape7', + tripMarkerShape8 = 'tripMarkerShape8', + tripMarkerShape9 = 'tripMarkerShape9', + tripMarkerShape10 = 'tripMarkerShape10' +} + +export enum MarkerIconContainer { + iconContainer1 = 'iconContainer1', + iconContainer2 = 'iconContainer2', + iconContainer3 = 'iconContainer3', + tripIconContainer1 = 'tripIconContainer1', + tripIconContainer2 = 'tripIconContainer2', + tripIconContainer3 = 'tripIconContainer3' +} + +const markerShapeMap = new Map( + [ + [MarkerShape.markerShape1, '/assets/markers/shape1.svg'], + [MarkerShape.markerShape2, '/assets/markers/shape2.svg'], + [MarkerShape.markerShape3, '/assets/markers/shape3.svg'], + [MarkerShape.markerShape4, '/assets/markers/shape4.svg'], + [MarkerShape.markerShape5, '/assets/markers/shape5.svg'], + [MarkerShape.markerShape6, '/assets/markers/shape6.svg'], + [MarkerShape.markerShape7, '/assets/markers/shape7.svg'], + [MarkerShape.markerShape8, '/assets/markers/shape8.svg'], + [MarkerShape.markerShape9, '/assets/markers/shape9.svg'], + [MarkerShape.markerShape10, '/assets/markers/shape10.svg'], + [MarkerShape.tripMarkerShape1, '/assets/markers/tripShape1.svg'], + [MarkerShape.tripMarkerShape2, '/assets/markers/tripShape2.svg'], + [MarkerShape.tripMarkerShape3, '/assets/markers/tripShape3.svg'], + [MarkerShape.tripMarkerShape4, '/assets/markers/tripShape4.svg'], + [MarkerShape.tripMarkerShape5, '/assets/markers/tripShape5.svg'], + [MarkerShape.tripMarkerShape6, '/assets/markers/tripShape6.svg'], + [MarkerShape.tripMarkerShape7, '/assets/markers/tripShape7.svg'], + [MarkerShape.tripMarkerShape8, '/assets/markers/tripShape8.svg'], + [MarkerShape.tripMarkerShape9, '/assets/markers/tripShape9.svg'], + [MarkerShape.tripMarkerShape10, '/assets/markers/tripShape10.svg'] + ] +); + +const markerIconContainerMap = new Map( + [ + [MarkerIconContainer.iconContainer1, '/assets/markers/iconContainer1.svg'], + [MarkerIconContainer.iconContainer2, '/assets/markers/iconContainer2.svg'], + [MarkerIconContainer.iconContainer3, '/assets/markers/iconContainer3.svg'], + [MarkerIconContainer.tripIconContainer1, '/assets/markers/tripIconContainer1.svg'], + [MarkerIconContainer.tripIconContainer2, '/assets/markers/tripIconContainer2.svg'], + [MarkerIconContainer.tripIconContainer3, '/assets/markers/tripIconContainer3.svg'] + ] +); + +interface MarkerIconContainerDefinition { + iconSize: number; + iconColor: (color: tinycolor.Instance) => tinycolor.Instance; + iconAlpha: (color: tinycolor.Instance) => number; + appendIcon?: (svgElement: SVGElement, iconElement: Element, iconSize: number) => void; +} + +const emptyIconContainerDefinition: MarkerIconContainerDefinition = { + iconSize: 24, + iconColor: (color) => color, + iconAlpha: color => color.getAlpha() +} + +const defaultIconContainerDefinition: MarkerIconContainerDefinition = { + iconSize: 12, + iconColor: (color) => tinycolor.mix(color.clone().setAlpha(1), tinycolor('rgba(0,0,0,0.38)')), + iconAlpha: color => color.getAlpha() +} + +const defaultTripIconContainerDefinition: MarkerIconContainerDefinition = { + iconSize: 24, + iconColor: () => tinycolor('#000'), + iconAlpha: () => 1, + appendIcon: (svgElement, iconElement, iconSize) => { + const iconCenter = calculateIconCenter(iconElement, iconSize); + const cx = iconCenter.cx; + const cy = iconCenter.cy; + let elements = svgElement.getElementsByClassName('icon-mask-exclude'); + if (elements.length) { + elements = elements[0].getElementsByClassName('marker-icon-container'); + if (elements.length) { + const iconContainer = new G(elements[0] as SVGGElement); + iconContainer.add(iconElement.clone().fill('#000').translate(-cx, -cy)); + } + } + elements = svgElement.getElementsByClassName('icon-mask-overlay'); + if (elements.length) { + elements = elements[0].getElementsByClassName('marker-icon-container'); + if (elements.length) { + const iconContainer = new G(elements[0] as SVGGElement); + iconContainer.add(iconElement.clone().fill('#fff').translate(-cx, -cy)); + } + } + } +} + +const markerIconContainerDefinitionMap = new Map( + [ + [MarkerIconContainer.iconContainer1, defaultIconContainerDefinition], + [MarkerIconContainer.iconContainer2, defaultIconContainerDefinition], + [MarkerIconContainer.iconContainer3, defaultIconContainerDefinition], + [MarkerIconContainer.tripIconContainer1, defaultTripIconContainerDefinition], + [MarkerIconContainer.tripIconContainer2, {...defaultTripIconContainerDefinition, iconSize: 16}], + [MarkerIconContainer.tripIconContainer3, {...defaultTripIconContainerDefinition, iconSize: 16}] + ] +); + +export const markerShapes = [ + MarkerShape.markerShape1, + MarkerShape.markerShape2, + MarkerShape.markerShape3, + MarkerShape.markerShape4, + MarkerShape.markerShape5, + MarkerShape.markerShape6, + MarkerShape.markerShape7, + MarkerShape.markerShape8, + MarkerShape.markerShape9, + MarkerShape.markerShape10 +]; + +export const tripMarkerShapes = [ + MarkerShape.tripMarkerShape1, + MarkerShape.tripMarkerShape2, + MarkerShape.tripMarkerShape3, + MarkerShape.tripMarkerShape4, + MarkerShape.tripMarkerShape5, + MarkerShape.tripMarkerShape6, + MarkerShape.tripMarkerShape7, + MarkerShape.tripMarkerShape8, + MarkerShape.tripMarkerShape9, + MarkerShape.tripMarkerShape10 +]; + +export const markerIconContainers = [ + MarkerIconContainer.iconContainer1, + MarkerIconContainer.iconContainer2, + MarkerIconContainer.iconContainer3 +]; + +export const tripMarkerIconContainers = [ + MarkerIconContainer.tripIconContainer1, + MarkerIconContainer.tripIconContainer2, + MarkerIconContainer.tripIconContainer3 +]; + +const generateElementId = () => { + const id = guid(); + const firstChar = id.charAt(0); + if (firstChar >= '0' && firstChar <= '9') { + return 'a' + id; + } else { + return id; + } +}; + +const prepareSvgIds = (element: SVGElement): SVGElement => { + let svgContent = element.outerHTML; + const regexp = /\sid="([^"]*)"[\s>]/g; + const unique_id_suffix = '_' + generateElementId(); + const ids: string[] = []; + let match = regexp.exec(svgContent); + while (match !== null) { + ids.push(match[1]); + match = regexp.exec(svgContent); + } + for (const id of ids) { + const newId = id + unique_id_suffix; + svgContent = svgContent.replace(new RegExp('id="'+id+'"', 'g'), 'id="'+newId+'"'); + svgContent = svgContent.replace(new RegExp('url\\(#'+id+'\\)', 'g'), 'url(#'+newId+')'); + } + return SVG(svgContent).node; +}; + +const createColorMarkerShape = (iconRegistry: MatIconRegistry, domSanitizer: DomSanitizer, assetUrl: string, color: tinycolor.Instance): Observable => { + const safeUrl = domSanitizer.bypassSecurityTrustResourceUrl(assetUrl); + return iconRegistry.getSvgIconFromUrl(safeUrl).pipe( + map((svgElement) => { + const colorElements = Array.from(svgElement.getElementsByClassName('marker-color')); + if (svgElement.classList.contains('marker-color')) { + colorElements.push(svgElement); + } + colorElements.forEach(el => { + el.setAttribute('fill', '#'+color.toHex()); + el.setAttribute('fill-opacity', `${color.getAlpha()}`); + }); + const opacityElements = Array.from(svgElement.getElementsByClassName('marker-opacity')); + if (svgElement.classList.contains('marker-opacity')) { + opacityElements.push(svgElement); + } + opacityElements.forEach(el => { + el.setAttribute('opacity', `${color.getAlpha()}`); + }); + return prepareSvgIds(svgElement); + }) + ); +} + + +export const createColorMarkerShapeURI = (iconRegistry: MatIconRegistry, domSanitizer: DomSanitizer, shape: MarkerShape, color: tinycolor.Instance): Observable => { + const assetUrl = markerShapeMap.get(shape); + return createColorMarkerShape(iconRegistry, domSanitizer, assetUrl, color).pipe( + map((svgElement) => { + const svg = svgElement.outerHTML; + return 'data:image/svg+xml;base64,' + btoa(svg); + }) + ); +} + +const createIconElement = (iconRegistry: MatIconRegistry, icon: string, size: number, iconColor: tinycolor.Instance, iconAlpha: number): Observable => { + const isSvg = isSvgIcon(icon); + if (isSvg) { + const [namespace, iconName] = splitIconName(icon); + return iconRegistry + .getNamedSvgIcon(iconName, namespace) + .pipe( + take(1), + map((svgElement) => { + const element = new Element(svgElement.firstChild); + element.fill('#'+iconColor.toHex()); + element.attr('fill-opacity', iconAlpha); + const scale = size / 24; + element.scale(scale); + return element; + }), + catchError(() => of(null)) + ); + } else { + const iconName = splitIconName(icon)[1]; + const textElement = new Text(document.createElementNS('http://www.w3.org/2000/svg', 'text')); + const fontSetClasses = ( + iconRegistry.getDefaultFontSetClass() + ).filter(className => className.length > 0); + fontSetClasses.forEach(className => textElement.addClass(className)); + textElement.font({size: `${size}px`}); + textElement.attr({ + style: `font-size: ${size}px`, + 'text-anchor': 'start' + }); + textElement.fill('#'+iconColor.toHex()); + textElement.attr('fill-opacity', iconAlpha); + const tspan = textElement.tspan(iconName); + tspan.attr({ + 'dominant-baseline': 'hanging' + }); + return of(textElement); + } +} + +export const createColorMarkerIconElement = (iconRegistry: MatIconRegistry, domSanitizer: DomSanitizer, + iconContainer: MarkerIconContainer, icon: string, color: tinycolor.Instance): Observable => { + const markerShape$: Observable = iconContainer ? + createColorMarkerShape(iconRegistry, domSanitizer, markerIconContainerMap.get(iconContainer), color) : of(null); + return markerShape$.pipe( + switchMap((svgElement) => { + const definition = iconContainer ? markerIconContainerDefinitionMap.get(iconContainer) : emptyIconContainerDefinition; + const iconSize = definition.iconSize; + const iconColor = definition.iconColor(color); + const iconAlpha = definition.iconAlpha(color); + return createIconElement(iconRegistry, icon, iconSize, iconColor, iconAlpha).pipe( + map((iconElement) => { + if (svgElement) { + if (iconElement) { + if (definition.appendIcon) { + definition.appendIcon(svgElement, iconElement, iconSize); + } else { + const elements = svgElement.getElementsByClassName('marker-icon-container'); + if (elements.length) { + const iconContainer = new G(elements[0] as SVGGElement); + iconContainer.add(iconElement); + const iconCenter = calculateIconCenter(iconElement, iconSize); + iconElement.translate(-iconCenter.cx, -iconCenter.cy); + } + } + } + return svgElement; + } else { + const svg = SVG(); + svg.viewbox(0,0,iconSize,iconSize); + const iconContainer = new G(); + iconContainer.translate(iconSize/2,iconSize/2); + iconContainer.add(iconElement); + const iconCenter = calculateIconCenter(iconElement, iconSize); + iconElement.translate(-iconCenter.cx, -iconCenter.cy); + svg.add(iconContainer); + return svg.node; + } + }) + ); + }) + ); +} + +let placeItemIconURI$: Observable; + +export const createPlaceItemIcon = (iconRegistry: MatIconRegistry, domSanitizer: DomSanitizer): Observable => { + if (placeItemIconURI$) { + return placeItemIconURI$; + } + placeItemIconURI$ = createColorMarkerShapeURI(iconRegistry, domSanitizer, MarkerShape.markerShape1, tinycolor('rgba(255,255,255,0.75)')).pipe( + shareReplay({refCount: true, bufferSize: 1}) + ); + return placeItemIconURI$; +} + +const calculateIconCenter = (iconElement: Element, iconSize: number): {cx: number, cy: number} => { + const box = iconElement.bbox(); + if (iconElement.type === 'text') { + return { + cx: iconSize/2 + box.x, + cy: iconSize/2 + box.y + }; + } else { + return { + cx: box.cx, + cy: box.cy + }; + } +} diff --git a/ui-ngx/src/app/shared/models/widget/widget-export.models.ts b/ui-ngx/src/app/shared/models/widget/widget-export.models.ts new file mode 100644 index 0000000000..5b47010285 --- /dev/null +++ b/ui-ngx/src/app/shared/models/widget/widget-export.models.ts @@ -0,0 +1,35 @@ +/// +/// 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 { Widget } from '@shared/models/widget.models'; +import { Dashboard } from '@shared/models/dashboard.models'; +import { EntityAliases } from '@shared/models/alias.models'; +import { Filters } from '@shared/models/query/query.models'; +import { MapExportDefinition } from '@shared/models/widget/maps/map-export.models'; + +export interface WidgetExportDefinition { + testWidget(widget: Widget): boolean; + prepareExportInfo(dashboard: Dashboard, widget: Widget): T; + updateFromExportInfo(widget: Widget, entityAliases: EntityAliases, filters: Filters, info: T): void; +} + +const widgetExportDefinitions: WidgetExportDefinition[] = [ + MapExportDefinition +]; + +export const getWidgetExportDefinition = (widget: Widget): WidgetExportDefinition => { + return widgetExportDefinitions.find(def => def.testWidget(widget)); +} diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 9b02d49c8f..8eec19379e 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -224,6 +224,8 @@ import { IntervalOptionsConfigPanelComponent } from '@shared/components/time/int import { GroupingIntervalOptionsComponent } from '@shared/components/time/aggregation/grouping-interval-options.component'; import { JsFuncModulesComponent } from '@shared/components/js-func-modules.component'; import { JsFuncModuleRowComponent } from '@shared/components/js-func-module-row.component'; +import { EntityKeyAutocompleteComponent } from '@shared/components/entity/entity-key-autocomplete.component'; +import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -243,6 +245,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) ShortNumberPipe, ImagePipe, CustomTranslatePipe, + DurationLeftPipe, { provide: FlowInjectionToken, useValue: Flow @@ -432,7 +435,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) ImageGalleryDialogComponent, WidgetButtonComponent, HexInputComponent, - ScadaSymbolInputComponent + ScadaSymbolInputComponent, + EntityKeyAutocompleteComponent, ], imports: [ CommonModule, @@ -694,7 +698,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) EmbedImageDialogComponent, ImageGalleryDialogComponent, WidgetButtonComponent, - ScadaSymbolInputComponent + ScadaSymbolInputComponent, + EntityKeyAutocompleteComponent, ] }) export class SharedModule { } diff --git a/ui-ngx/src/assets/help/en_US/calculated-field/examples/merge-functions/merge_all_output.md b/ui-ngx/src/assets/help/en_US/calculated-field/examples/merge-functions/merge_all_output.md new file mode 100644 index 0000000000..b3b15f8f3d --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/calculated-field/examples/merge-functions/merge_all_output.md @@ -0,0 +1,25 @@ +#### Output: + +```json +{ + "mergedData": { + "timeWindow": { + "startTs": 1741356332086, + "endTs": 1741357232086 + }, + "values": [{ + "ts": 1741357047945, + "values": [76.0, 46.0, 1023.0] + }, { + "ts": 1741357056144, + "values": [76.0, 46.0, 1026.0] + }, { + "ts": 1741357063689, + "values": [77.0, 46.0, 1026.0] + }, { + "ts": 1741357147391, + "values": [77.0, 46.0, 1025.0] + }] + } +} +``` diff --git a/ui-ngx/src/assets/help/en_US/calculated-field/examples/merge-functions/merge_all_usage.md b/ui-ngx/src/assets/help/en_US/calculated-field/examples/merge-functions/merge_all_usage.md new file mode 100644 index 0000000000..89d3f0c84f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/calculated-field/examples/merge-functions/merge_all_usage.md @@ -0,0 +1,5 @@ +#### Usage: + +```javascript +var mergedData = temperature.mergeAll([humidity, pressure], { ignoreNaN: true }); +``` diff --git a/ui-ngx/src/assets/help/en_US/calculated-field/examples/merge-functions/merge_input.md b/ui-ngx/src/assets/help/en_US/calculated-field/examples/merge-functions/merge_input.md new file mode 100644 index 0000000000..f41b4ae2da --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/calculated-field/examples/merge-functions/merge_input.md @@ -0,0 +1,48 @@ +#### Assuming the following arguments and their values: + +```json +{ + "humidity": { + "timeWindow": { + "startTs": 1741356332086, + "endTs": 1741357232086 + }, + "values": [{ + "ts": 1741356882759, + "value": 43 + }, { + "ts": 1741356918779, + "value": 46 + }] + }, + "pressure": { + "timeWindow": { + "startTs": 1741356332086, + "endTs": 1741357232086 + }, + "values": [{ + "ts": 1741357047945, + "value": 1023 + }, { + "ts": 1741357056144, + "value": 1026 + }, { + "ts": 1741357147391, + "value": 1025 + }] + }, + "temperature": { + "timeWindow": { + "startTs": 1741356332086, + "endTs": 1741357232086 + }, + "values": [{ + "ts": 1741356874943, + "value": 76 + }, { + "ts": 1741357063689, + "value": 77 + }] + } +} +``` diff --git a/ui-ngx/src/assets/help/en_US/calculated-field/examples/merge-functions/merge_output.md b/ui-ngx/src/assets/help/en_US/calculated-field/examples/merge-functions/merge_output.md new file mode 100644 index 0000000000..545ef9994c --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/calculated-field/examples/merge-functions/merge_output.md @@ -0,0 +1,25 @@ +#### Output: + +```json +{ + "mergedData": { + "timeWindow": { + "startTs": 1741356332086, + "endTs": 1741357232086 + }, + "values": [{ + "ts": 1741356874943, + "values": [76.0, "NaN"] + }, { + "ts": 1741356882759, + "values": [76.0, 43.0] + }, { + "ts": 1741356918779, + "values": [76.0, 46.0] + }, { + "ts": 1741357063689, + "values": [77.0, 46.0] + }] + } +} +``` diff --git a/ui-ngx/src/assets/help/en_US/calculated-field/examples/merge-functions/merge_usage.md b/ui-ngx/src/assets/help/en_US/calculated-field/examples/merge-functions/merge_usage.md new file mode 100644 index 0000000000..a126196ee9 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/calculated-field/examples/merge-functions/merge_usage.md @@ -0,0 +1,5 @@ +#### Usage: + +```javascript +var mergedData = temperature.merge(humidity, { ignoreNaN: false }); +``` diff --git a/ui-ngx/src/assets/help/en_US/calculated-field/expression_fn.md b/ui-ngx/src/assets/help/en_US/calculated-field/expression_fn.md new file mode 100644 index 0000000000..373c50e2c6 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/calculated-field/expression_fn.md @@ -0,0 +1,289 @@ +## Calculated Field TBEL Script Function + +The **calculate()** function is a user-defined script that enables custom calculations using [TBEL](${siteBaseUrl}/docs${docPlatformPrefix}/user-guide/tbel/) on telemetry and attribute data. +It receives arguments configured in the calculated field setup, along with an additional `ctx` object that provides access to all arguments. + +### Function Signature + +```javascript +function calculate(ctx, arg1, arg2, ...): object | object[] +``` + +### Supported Arguments + +There are three types of arguments supported in the calculated field configuration: + +#### Attribute and Latest Telemetry Arguments + +These arguments are single values and may be of type: boolean, int64 (long), double, string, or JSON. + +**Example: Convert Temperature from Fahrenheit to Celsius** + +```javascript +var temperatureC = (temperatureF - 32) / 1.8; +return { + "temperatureC": toFixed(temperatureC, 2) +} +``` + +Alternatively, using `ctx` to access the argument as an object: + +```json +{ + "temperatureF": { + "ts": 1740644636669, + "value": 36.6 + } +} +``` + +You may notice that the object includes both the `value` of an argument and its timestamp as `ts`. +Let's modify the function that converts Fahrenheit to Celsius to also return the timestamp information: + +```javascript +var temperatureC = (temperatureF - 32) / 1.8; +return { + "ts": ctx.args.temperatureF.ts, + "values": { "temperatureC": toFixed(temperatureC, 2) } +}; +``` + +#### Time Series Rolling Arguments + +These contain time series data within a defined time window. Example format: + +```json +{ + "temperature": { + "timeWindow": { + "startTs": 1740643762896, + "endTs": 1740644662896 + }, + "values": [ + { "ts": 1740644350000, "value": 72.32 }, + { "ts": 1740644360000, "value": 72.86 }, + { "ts": 1740644370000, "value": 73.58 }, + { "ts": 1740644380000, "value": "NaN" } + ] + } +} +``` + +The values are always converted to type `double`, and `NaN` is used when conversion fails. One may use `isNaN(double): boolean` function to check that the value is a valid number. + +**Example: Accessing time series rolling argument data** + +```javascript +var startOfInterval = temperature.timeWindow.startTs; +var endOfInterval = temperature.timeWindow.endTs; +var firstItem = temperature.values[0]; +var firstItemTs = firstItem.ts; +var firstItemValue = firstItem.value; +var sum = 0.0; +// iterate through all values and calculate the sum using foreach: +foreach(t: temperature) { + if(!isNaN(t.value)) { // check that the value is a valid number; + sum += t.value; + } +} +// iterate through all values and calculate the sum using for loop: +sum = 0.0; +for(var i = 0; i < temperature.values.size; i++) { + sum += temperature.values[i].value; +} +// use built-in function to calculate the sum +sum = temperature.sum(); +``` + +##### Built-in Methods for Rolling Arguments + +Time series rolling arguments support built-in functions for calculations. These functions accept an optional `ignoreNaN` boolean parameter. + +| Method | Default Behavior (`ignoreNaN = true`) | Alternative (`ignoreNaN = false`) | +|-----------------|-----------------------------------------------------|---------------------------------------------| +| `max()` | Returns the highest value, ignoring NaN values. | Returns NaN if any NaN values exist. | +| `min()` | Returns the lowest value, ignoring NaN values. | Returns NaN if any NaN values exist. | +| `mean(), avg()` | Computes the average value, ignoring NaN values. | Returns NaN if any NaN values exist. | +| `std()` | Calculates the standard deviation, ignoring NaN. | Returns NaN if any NaN values exist. | +| `median()` | Returns the median value, ignoring NaN values. | Returns NaN if any NaN values exist. | +| `count()` | Counts values, ignoring NaN values. | Counts all values, including NaN. | +| `last()` | Returns the most recent value, skipping NaN values. | Returns the last value, even if it is NaN. | +| `first()` | Returns the oldest value, skipping NaN values. | Returns the first value, even if it is NaN. | +| `sum()` | Computes the total sum, ignoring NaN values. | Returns NaN if any NaN values exist. | + +Usage example: + +```javascript +var avgTemp = temperature.mean(); // Returns 72.92 +var tempMax = temperature.max(); // Returns 73.58 +var valueCount = temperature.count(); // Returns 3 + +var avgTempNaN = temperature.mean(false); // Returns NaN +var tempMaxNaN = temperature.max(false); // Returns NaN +var valueCountNaN = temperature.count(false); // Returns 4 +``` + +This function calculates air density using `altitude` (single value) and `temperature` (time series rolling argument). + +```javascript +function calculate(ctx, altitude, temperature) { + var avgTemperature = temperature.mean(); // Get average temperature + var temperatureK = (avgTemperature - 32) * (5 / 9) + 273.15; // Convert Fahrenheit to Kelvin + + // Estimate air pressure based on altitude + var pressure = 101325 * Math.pow((1 - 2.25577e-5 * altitude), 5.25588); + + // Air density formula + var airDensity = pressure / (287.05 * temperatureK); + + return { + "airDensity": toFixed(airDensity, 2) + }; +} +``` + +##### Merging Time Series Arguments + +Time series rolling arguments can be **merged** to align timestamps across multiple datasets. + +| Method | Description | Returns | Example | +|:-----------------------------|:--------------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `merge(other, settings)` | Merges with another rolling argument. Aligns timestamps and filling missing values with the previous available value. | Merged object with `timeWindow` and aligned values. |

    | +| `mergeAll(others, settings)` | Merges multiple rolling arguments. Aligns timestamps and filling missing values with the previous available value. | Merged object with `timeWindow` and aligned values. |

    | + +##### Parameters +| Parameter | Description | +|:---------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `other` or `others` | Another rolling argument or array of rolling arguments to merge with. | +| `settings`(optional) | Configuration object that supports:
    • `ignoreNaN` - controls whether NaN values should be ignored.
    • `timeWindow` - defines a custom time window.
    | + +**Example: Freezer temperature analysis** + +This function merges `temperature` data with the fridge's `defrost` status. It then analyzes the merged data to identify instances where the fridge is not in defrost mode, yet the internal air temperature is too high ( > -5° C). + +```javascript +function calculate(ctx, temperature, defrost) { + var merged = temperature.merge(defrost); + var result = []; + + foreach(item: merged) { + if (item.v1 > -5.0 && item.v2 == 0) { + result.add({ + ts: item.ts, + values: { + issue: { + temperature: item.v1, + defrostState: false + } + } + }); + } + } + + return result; +} +``` + +The result is a list of issues that may be used to configure alarm rules: + +```json +[{ + "ts": 1741613833843, + "values": { + "issue": { + "temperature": -3.12, + "defrostState": false + } + } +}, { + "ts": 1741613923848, + "values": { + "issue": { + "temperature": -4.16, + "defrostState": false + } + } +}] +``` + +### Function return format + +The return format depends on the output type configured in the calculated field settings (default: **Time Series**). + +##### Time Series Output + +The function must return a JSON object or array with or without a timestamp. +Examples below return 5 data points: airDensity (double), humidity (integer), hvacEnabled (boolean), hvacState (string) and configuration (JSON): + +Without timestamp: + +```json +{ + "airDensity": 1.06, + "humidity": 70, + "hvacEnabled": true, + "hvacState": "IDLE", + "configuration": { + "someNumber": 42, + "someArray": [1,2,3], + "someNestedObject": {"key": "value"} + } +} +``` + +With timestamp: + +```json +{ + "ts": 1740644636669, + "values": { + "airDensity": 1.06, + "humidity": 70, + "hvacEnabled": true, + "hvacState": "IDLE", + "configuration": { + "someNumber": 42, + "someArray": [1,2,3], + "someNestedObject": {"key": "value"} + } + } +} +``` + +Array containing multiple timestamps and different values of the `airDensity` : + +```json +[ + { + "ts": 1740644636669, + "values": { + "airDensity": 1.06 + } + }, + { + "ts": 1740644636670, + "values": { + "airDensity": 1.07 + } + } +] +``` + +##### Attribute Output + +The function must return a JSON object **without timestamp** information. +Example below return 5 data points: airDensity (double), humidity (integer), hvacEnabled (boolean), hvacState (string) and configuration (JSON): + +```json +{ + "airDensity": 1.06, + "humidity": 70, + "hvacEnabled": true, + "hvacState": "IDLE", + "configuration": { + "someNumber": 42, + "someArray": [1,2,3], + "someNestedObject": {"key": "value"} + } +} +``` diff --git a/ui-ngx/src/assets/help/en_US/rulenode/save_attributes_node_advanced.md b/ui-ngx/src/assets/help/en_US/rulenode/save_attributes_node_advanced.md new file mode 100644 index 0000000000..f0705baa6f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/save_attributes_node_advanced.md @@ -0,0 +1,32 @@ +#### Potential unexpected behavior with mixed processing strategies + +When configuring the processing strategies, certain combinations can lead to unexpected behavior. Consider the following scenarios: + +- **Skipping database storage** + + Choosing to disable attribute persistence introduces the risk of having only partial data available. + For example, if a message is processed solely for real-time notifications via WebSockets and not stored in the database, then attribute queries might not reflect the data visible on the dashboard. + +- **Disabling WebSocket (WS) updates** + + If WS updates are disabled, any changes to the attribute data won’t be pushed to dashboards (or other WS subscriptions). + This means that even if a database is updated, dashboards may not display the updated data until browser page is reloaded. + +- **Skipping calculated field recalculation** + + If attribute data is saved to the database while bypassing calculated field recalculation, the aggregated value may not update to reflect the saved data. + Conversely, if the calculated field is recalculated with new data but the corresponding attribute value is not persisted in the database, the calculated field's value might include data that isn’t stored. + +- **Different deduplication intervals across actions** + + When you configure different deduplication intervals for actions, the same incoming message might be processed differently for each action. + For example, a message might be stored immediately in the Attributes table (if set to *On every message*) while not being present on a dashboard because its deduplication interval hasn’t elapsed. + +- **Deduplication cache clearing** + + The deduplication mechanism uses a cache to track processed messages within each interval. + For performance and system stability reasons, this cache is periodically cleared. + As a result, if a cache entry is removed during the deduplication period, messages from the same originator may be processed more than once within that interval. + This means deduplication should be used as a performance optimization rather than an absolute guarantee of single processing per interval. + +We recommend using deduplication only when the occasional repeated processing is acceptable and won't cause system correctness issue or data inconsistencies. diff --git a/ui-ngx/src/assets/help/en_US/rulenode/save_timeseries_node_advanced.md b/ui-ngx/src/assets/help/en_US/rulenode/save_timeseries_node_advanced.md index 44a037f180..18c0f60cdb 100644 --- a/ui-ngx/src/assets/help/en_US/rulenode/save_timeseries_node_advanced.md +++ b/ui-ngx/src/assets/help/en_US/rulenode/save_timeseries_node_advanced.md @@ -2,23 +2,28 @@ When configuring the processing strategies, certain combinations can lead to unexpected behavior. Consider the following scenarios: +- **Skipping database storage** + + Choosing to disable one or more persistence actions (for instance, skipping database storage for Time series or Latest values while keeping WS updates enabled) introduces the risk of having only partial data available: + - If a message is processed only for real-time notifications (WebSockets) and not stored in the database, historical queries may not match data on the dashboard. + - When processing strategies for Time series and Latest values are out-of-sync, telemetry data may be stored in one table (e.g., Time series) while the same data is absent in the other (e.g., Latest values). + - **Disabling WebSocket (WS) updates** If WS updates are disabled, any changes to the time series data won’t be pushed to dashboards (or other WS subscriptions). This means that even if a database is updated, dashboards may not display the updated data until browser page is reloaded. +- **Skipping calculated field recalculation** + + If telemetry data is saved to the database while bypassing calculated field recalculation, the aggregated value may not update to reflect the latest data. + Conversely, if the calculated field is recalculated with new data but the corresponding telemetry value is not persisted in the database, the calculated field's value might include data that isn’t stored. + - **Different deduplication intervals across actions** When you configure different deduplication intervals for actions, the same incoming message might be processed differently for each action. For example, a message might be stored immediately in the Time series table (if set to *On every message*) while not being stored in the Latest values table because its deduplication interval hasn’t elapsed. Also, if the WebSocket updates are configured with a different interval, dashboards might show updates that do not match what is stored in the database. -- **Skipping database storage** - - Choosing to disable one or more persistence actions (for instance, skipping database storage for Time series or Latest values while keeping WS updates enabled) introduces the risk of having only partial data available: - - If a message is processed only for real-time notifications (WebSockets) and not stored in the database, historical queries may not match data on the dashboard. - - When processing strategies for Time series and Latest values are out-of-sync, telemetry data may be stored in one table (e.g., Time series) while the same data is absent in the other (e.g., Latest values). - - **Deduplication cache clearing** The deduplication mechanism uses a cache to track processed messages within each interval. diff --git a/ui-ngx/src/assets/help/en_US/widget/config/parse_value_get_dashboard_state_id_fn.md b/ui-ngx/src/assets/help/en_US/widget/config/parse_value_get_dashboard_state_id_fn.md new file mode 100644 index 0000000000..3a580b4136 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/config/parse_value_get_dashboard_state_id_fn.md @@ -0,0 +1,33 @@ +#### Parse value function + +
    +
    + +*function (data): boolean* + +A JavaScript function that converts the current dashboard state id into a boolean value. + +**Parameters:** + +
      +
    • data: string - the current dashboard state id. +
    • +
    + +**Returns:** + +`true` if the widget should be in an activated state, `false` otherwise. + +
    + +##### Examples + +* Check if the current dashboard state id is "default": + +```javascript +return data === 'default' ? true : false; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/clustering_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/clustering_color_fn.md new file mode 100644 index 0000000000..ba58fa3cd3 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/clustering_color_fn.md @@ -0,0 +1,65 @@ +#### Clustering marker function + +
    +
    + +*function (data, childCount): string* + +A JavaScript function used to compute clustering marker color. + +**Parameters:** + +
      +
    • data: FormattedData[] + - the array of total markers contained within each cluster.
      + Represents basic entity properties (ex. entityId, entityName)
      and provides access to other entity attributes/timeseries declared in widget datasource configuration. +
    • +
    • childCount: number - the total number of markers contained within that cluster +
    • +
    + +**Returns:** + +Should return string value presenting color of the marker. + +In case no data is returned, color value from **Color** settings field will be used. + +
    + +##### Examples + +
      +
    • +Calculate color depending on temperature telemetry value: +
    • + + +```javascript +let customColor; +for (let markerData of data) { + if (markerData.temperature > 40) { + customColor = 'red' + } +} +return customColor ? customColor : 'green'; +{:copy-code} +``` + +
    • +Calculate color depending on childCount: +
    • + +```javascript +if (childCount < 10) { + return 'green'; +} else if (childCount < 100) { + return 'yellow'; +} else { + return 'red'; +} +{:copy-code} +``` + +
    +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/color_fn.md new file mode 100644 index 0000000000..2f461e28a6 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/color_fn.md @@ -0,0 +1,42 @@ +#### Marker color function + +
    +
    + +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute color of the marker. + +**Parameters:** + +
      + {% include widget/lib/map-legacy/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting color of the marker. + +In case no data is returned, color value from **Color** settings field will be used. + +
    + +##### Examples + +* Calculate color depending on `temperature` telemetry value for `colorpin` device type: + +```javascript +var type = data['Type']; +if (type == 'colorpin') { + var temperature = data['temperature']; + if (typeof temperature !== undefined) { + var percent = (temperature + 60)/120 * 100; + return tinycolor.mix('blue', 'red', percent).toHexString(); + } + return 'blue'; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/label_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/label_fn.md new file mode 100644 index 0000000000..adfdcc287c --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/label_fn.md @@ -0,0 +1,40 @@ +#### Marker label function + +
    +
    + +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute text or HTML code of the marker label. + +**Parameters:** + +
      + {% include widget/lib/map-legacy/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting text or HTML of the marker label. + +
    + +##### Examples + +* Display styled label with corresponding latest telemetry data for `energy meter` or `thermometer` device types: + +```javascript +var deviceType = data['Type']; +if (typeof deviceType !== undefined) { + if (deviceType == "energy meter") { + return '${entityName}, ${energy:2} kWt'; + } else if (deviceType == "thermometer") { + return '${entityName}, ${temperature:2} °C'; + } +} +return data.entityName; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/map_fn_args.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/map_fn_args.md new file mode 100644 index 0000000000..7a4f24f0ac --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/map_fn_args.md @@ -0,0 +1,10 @@ +
  • data: FormattedData - A FormattedData object associated with marker or data point of the route.
    + Represents basic entity properties (ex. entityId, entityName)
    and provides access to other entity attributes/timeseries declared in widget datasource configuration. +
  • +
  • dsData: FormattedData[] - All available data associated with markers or routes data points 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 widget datasource configuration. +
  • +
  • dsIndex number - index of the current marker data or route data point in dsData array.
    + Note: The data argument is equivalent to dsData[dsIndex] expression. +
  • diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/marker_image_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/marker_image_fn.md new file mode 100644 index 0000000000..2ef0f4d645 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/marker_image_fn.md @@ -0,0 +1,62 @@ +#### Marker image function + +
    +
    + +*function (data, images, dsData, dsIndex): {url: string, size: number}* + +A JavaScript function used to compute marker image. + +**Parameters:** + +
      + {% include widget/lib/map-legacy/map_fn_args %} +
    + +**Returns:** + +Should return marker image data having the following structure: + +```typescript +{ + url: string, + size: number +} +``` + +- *url* - marker image url; +- *size* - marker image size; + +In case no data is returned, default marker image will be used. + +
    + +##### Examples + +
      +
    • +Calculate image url depending on temperature telemetry value for thermometer device type.
      +Let's assume 4 images are defined in Marker images section. Each image corresponds to particular temperature level: +
    • +
    + +```javascript +var type = data['Type']; +if (type == 'thermometer') { + var res = { + url: images[0], + size: 40 + } + var temperature = data['temperature']; + if (typeof temperature !== undefined) { + var percent = (temperature + 60)/120; + var index = Math.min(3, Math.floor(4 * percent)); + res.url = images[index]; + } + return res; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/path_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/path_color_fn.md new file mode 100644 index 0000000000..01d3d09ea7 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/path_color_fn.md @@ -0,0 +1,42 @@ +#### Path color function + +
    +
    + +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute color of the trip path. + +**Parameters:** + +
      + {% include widget/lib/map-legacy/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting color of the trip path. + +In case no data is returned, color value from **Path color** settings field will be used. + +
    + +##### Examples + +* Calculate color depending on `temperature` telemetry value for `colorpin` device type: + +```javascript +var type = data['Type']; +if (type == 'colorpin') { + var temperature = data['temperature']; + if (typeof temperature !== undefined) { + var percent = (temperature + 60)/120 * 100; + return tinycolor.mix('blue', 'red', percent).toHexString(); + } + return 'blue'; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/path_point_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/path_point_color_fn.md new file mode 100644 index 0000000000..83a4af5bc4 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/path_point_color_fn.md @@ -0,0 +1,43 @@ +#### Path point color function + +
    +
    + +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute color of the trip path point. + +**Parameters:** + +
      + {% include widget/lib/map-legacy/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting color of the trip path point. + +In case no data is returned, color value from **Point color** settings field will be used. + +
    + +##### Examples + +* Calculate color depending on `temperature` telemetry value for `colorpin` device type: + +```javascript +var type = data['Type']; +if (type == 'colorpin') { + var temperature = data['temperature']; + if (typeof temperature !== undefined) { + var percent = (temperature + 60)/120 * 100; + return tinycolor.mix('blue', 'red', percent).toHexString(); + } + return 'blue'; +} +{:copy-code} +``` + +
    +
    + diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/polygon_color_fn.md similarity index 94% rename from ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_color_fn.md rename to ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/polygon_color_fn.md index b4cc0c5223..45edf0d2a5 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_color_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/polygon_color_fn.md @@ -10,7 +10,7 @@ A JavaScript function used to compute color of the polygon. **Parameters:**
      - {% include widget/lib/map/map_fn_args %} + {% include widget/lib/map-legacy/map_fn_args %}
    **Returns:** diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/polygon_tooltip_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/polygon_tooltip_fn.md new file mode 100644 index 0000000000..b583e93228 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/polygon_tooltip_fn.md @@ -0,0 +1,40 @@ +#### Polygon tooltip function + +
    +
    + +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute text or HTML code to be displayed in the polygon tooltip. + +**Parameters:** + +
      + {% include widget/lib/map-legacy/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting text or HTML for the polygon tooltip. + +
    + +##### Examples + +* Display details with corresponding telemetry data for `energy meter` or `thermostat` device types: + +```javascript +var deviceType = data['Type']; +if (typeof deviceType !== undefined) { + if (deviceType == "energy meter") { + return '${entityName}
    Energy: ${energy:2} kWt
    '; + } else if (deviceType == "thermostat") { + return '${entityName}
    Temperature: ${temperature:2} °C
    '; + } +} +return data.entityName; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/position_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/position_fn.md new file mode 100644 index 0000000000..fa33714fbd --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/position_fn.md @@ -0,0 +1,65 @@ +#### Position conversion function + +
    +
    + +*function (origXPos, origYPos, data, dsData, dsIndex, aspect): {x: number, y: number}* + +A JavaScript function used to convert original relative x, y coordinates of the marker. + +**Parameters:** + +
      +
    • origXPos: number - original relative x coordinate as double from 0 to 1.
    • +
    • origYPos: number - original relative y coordinate as double from 0 to 1.
    • + {% include widget/lib/map-legacy/map_fn_args %} +
    • aspect: number - image map aspect ratio.
    • +
    + +**Returns:** + +Should return position data having the following structure: + +```typescript +{ + x: number, + y: number +} +``` + +- *x* - new relative x coordinate as double from 0 to 1; +- *y* - new relative y coordinate as double from 0 to 1; + +
    + +##### Examples + +* Scale the coordinates to half the original: + +```javascript +return {x: origXPos / 2, y: origYPos / 2}; +{:copy-code} +``` + +* Detect markers with same positions and place them with minimum overlap: + +```javascript +var xPos = data.xPos; +var yPos = data.yPos; +var locationGroup = dsData.filter((item) => item.xPos === xPos && item.yPos === yPos); +if (locationGroup.length > 1) { + const count = locationGroup.length; + const index = locationGroup.indexOf(data); + const radius = 0.035; + const angle = (360 / count) * index - 45; + const x = xPos + radius * Math.sin(angle*Math.PI/180) / aspect; + const y = yPos + radius * Math.cos(angle*Math.PI/180); + return {x: x, y: y}; +} else { + return {x: xPos, y: yPos}; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/tooltip_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/tooltip_fn.md new file mode 100644 index 0000000000..8b290c2215 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/tooltip_fn.md @@ -0,0 +1,40 @@ +#### Marker tooltip function + +
    +
    + +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute text or HTML code to be displayed in the marker, point or polygon tooltip. + +**Parameters:** + +
      + {% include widget/lib/map-legacy/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting text or HTML for the tooltip. + +
    + +##### Examples + +* Display details with corresponding telemetry data for `thermostat` device type: + +```javascript +var deviceType = data['Type']; +if (typeof deviceType !== undefined) { + if (deviceType == "energy meter") { + return '${entityName}
    Energy: ${energy:2} kWt
    '; + } else if (deviceType == "thermometer") { + return '${entityName}
    Temperature: ${temperature:2} °C
    '; + } +} +return data.entityName; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/trip_point_as_anchor_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/trip_point_as_anchor_fn.md new file mode 100644 index 0000000000..eb11261f3d --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/trip_point_as_anchor_fn.md @@ -0,0 +1,34 @@ +#### Point as anchor function + +
    +
    + +*function (data, dsData, dsIndex): boolean* + +A JavaScript function evaluating whether to use trip point as time anchor used in time selector. + +**Parameters:** + +
      + {% include widget/lib/map-legacy/map_fn_args %} +
    + +**Returns:** + +`true` if the point should be decided as anchor, `false` otherwise. + +In case no data is returned, the point is not used as anchor. + +
    + +##### Examples + +* Make anchors with 5 seconds step interval: + +```javascript +return data.time % 5000 < 1000; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_fill_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_fill_color_fn.md new file mode 100644 index 0000000000..9a7a383876 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_fill_color_fn.md @@ -0,0 +1,24 @@ +#### Circle fill color function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute fill color of the circle. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting fill color of the circle. + +In case no data is returned, color value from **Color** settings field will be used. + +
    + +{% include widget/lib/map/color_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_label_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_label_fn.md new file mode 100644 index 0000000000..1b73148876 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_label_fn.md @@ -0,0 +1,22 @@ +#### Circle label function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute text or HTML code of the circle label. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting text or HTML of the circle label. + +
    + +{% include widget/lib/map/label_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_stroke_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_stroke_color_fn.md new file mode 100644 index 0000000000..31d0a0f82a --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_stroke_color_fn.md @@ -0,0 +1,24 @@ +#### Circle stroke color function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute stroke color of the circle. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting stroke color of the circle. + +In case no data is returned, color value from **Color** settings field will be used. + +
    + +{% include widget/lib/map/color_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_tooltip_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_tooltip_fn.md new file mode 100644 index 0000000000..1ea037f084 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_tooltip_fn.md @@ -0,0 +1,22 @@ +#### Circle tooltip function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute text or HTML code to be displayed in the circle tooltip. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting text or HTML for the tooltip. + +
    + +{% include widget/lib/map/tooltip_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/clustering_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/clustering_color_fn.md index ba58fa3cd3..33d4df2f18 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/clustering_color_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/clustering_color_fn.md @@ -10,9 +10,9 @@ A JavaScript function used to compute clustering marker color. **Parameters:**
      -
    • data: FormattedData[] +
    • data: FormattedData[] - the array of total markers contained within each cluster.
      - Represents basic entity properties (ex. entityId, entityName)
      and provides access to other entity attributes/timeseries declared in widget datasource configuration. + Represents basic entity properties (ex. entityId, entityName)
      and provides access to other entity attributes/timeseries declared in datasource of the data layer configuration.
    • childCount: number - the total number of markers contained within that cluster
    • @@ -22,7 +22,7 @@ A JavaScript function used to compute clustering marker color. Should return string value presenting color of the marker. -In case no data is returned, color value from **Color** settings field will be used. +In case no data is returned, default colors will be used depending on number of markers within that cluster.
      diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn.md index ede068d68f..fdecf9596c 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn.md @@ -3,7 +3,7 @@

      -*function (data, dsData, dsIndex): string* +*function (data, dsData): string* A JavaScript function used to compute color of the marker. @@ -21,22 +21,4 @@ In case no data is returned, color value from **Color** settings field will be u
      -##### Examples - -* Calculate color depending on `temperature` telemetry value for `colorpin` device type: - -```javascript -var type = data['Type']; -if (type == 'colorpin') { - var temperature = data['temperature']; - if (typeof temperature !== undefined) { - var percent = (temperature + 60)/120 * 100; - return tinycolor.mix('blue', 'red', percent).toHexString(); - } - return 'blue'; -} -{:copy-code} -``` - -
      -
      +{% include widget/lib/map/color_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn_examples.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn_examples.md new file mode 100644 index 0000000000..8c3631bde7 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn_examples.md @@ -0,0 +1,19 @@ +##### Examples + +* Calculate color depending on `temperature` telemetry value for `thermostat` device type: + +```javascript +var type = data.Type; +if (type == 'thermostat') { + var temperature = data.temperature; + if (typeof temperature !== undefined) { + var percent = (temperature + 60)/120 * 100; + return tinycolor.mix('blue', 'red', percent).toHexString(); + } + return 'blue'; +} +{:copy-code} +``` + +
      +
      diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn.md index eb3e6f981e..0028d77b82 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn.md @@ -3,7 +3,7 @@

      -*function (data, dsData, dsIndex): string* +*function (data, dsData): string* A JavaScript function used to compute text or HTML code of the marker label. @@ -19,22 +19,4 @@ Should return string value presenting text or HTML of the marker label.
      -##### Examples - -* Display styled label with corresponding latest telemetry data for `energy meter` or `thermometer` device types: - -```javascript -var deviceType = data['Type']; -if (typeof deviceType !== undefined) { - if (deviceType == "energy meter") { - return '${entityName}, ${energy:2} kWt'; - } else if (deviceType == "thermometer") { - return '${entityName}, ${temperature:2} °C'; - } -} -return data.entityName; -{:copy-code} -``` - -
      -
      +{% include widget/lib/map/label_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn_examples.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn_examples.md new file mode 100644 index 0000000000..2e2ecfabec --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn_examples.md @@ -0,0 +1,19 @@ +##### Examples + +* Display styled label with corresponding latest telemetry data for `energy meter` or `thermometer` device types: + +```javascript +var deviceType = data.Type; +if (typeof deviceType !== undefined) { + if (deviceType == "energy meter") { + return '${entityName}, ${energy:2} kWt'; + } else if (deviceType == "thermometer") { + return '${entityName}, ${temperature:2} °C'; + } +} +return data.entityName; +{:copy-code} +``` + +
      +
      diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/map_fn_args.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/map_fn_args.md index 7a4f24f0ac..a627e1867f 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/map_fn_args.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/map_fn_args.md @@ -1,10 +1,8 @@ -
    • data: FormattedData - A FormattedData object associated with marker or data point of the route.
      - Represents basic entity properties (ex. entityId, entityName)
      and provides access to other entity attributes/timeseries declared in widget datasource configuration. +
    • 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.
    • -
    • dsData: FormattedData[] - All available data associated with markers or routes data points as array of FormattedData objects
      +
    • 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 widget datasource configuration. -
    • -
    • dsIndex number - index of the current marker data or route data point in dsData array.
      - Note: The data argument is equivalent to dsData[dsIndex] expression. + 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/help/en_US/widget/lib/map/marker_image_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/marker_image_fn.md index 565de0147a..ed8b82d4cc 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/marker_image_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/marker_image_fn.md @@ -3,14 +3,14 @@

      -*function (data, images, dsData, dsIndex): {url: string, size: number}* +*function (data, images, dsData): {url: string, size: number}* A JavaScript function used to compute marker image. **Parameters:**
        - {% include widget/lib/map/map_fn_args %} + {% include widget/lib/map/marker_image_fn_args %}
      **Returns:** @@ -18,14 +18,18 @@ A JavaScript function used to compute marker image. Should return marker image data having the following structure: ```typescript -{ - url: string, - size: number +{ + url: string; + size: number; + markerOffset?: [number, number]; + tooltipOffset?: [number, number]; } ``` - *url* - marker image url; - *size* - marker image size; +- *markerOffset* - optional array of two numbers presenting relative horizontal and vertical offset of the marker image; +- *tooltipOffset* - optional array of two numbers presenting relative horizontal and vertical offset of the marker image tooltip; In case no data is returned, default marker image will be used. @@ -41,13 +45,13 @@ Let's assume 4 images are defined in Marker images section. Each image co
    ```javascript -var type = data['Type']; +var type = data.Type; if (type == 'thermometer') { var res = { url: images[0], size: 40 } - var temperature = data['temperature']; + var temperature = data.temperature; if (typeof temperature !== undefined) { var percent = (temperature + 60)/120; var index = Math.min(3, Math.floor(4 * percent)); diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/marker_image_fn_args.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/marker_image_fn_args.md new file mode 100644 index 0000000000..e86bd3106f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/marker_image_fn_args.md @@ -0,0 +1,10 @@ +
  • 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 Marker 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/help/en_US/widget/lib/map/path_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_color_fn.md index c4df0a99a9..7f90b9c8f7 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/path_color_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_color_fn.md @@ -3,7 +3,7 @@

    -*function (data, dsData, dsIndex): string* +*function (data, dsData): string* A JavaScript function used to compute color of the trip path. @@ -17,26 +17,8 @@ A JavaScript function used to compute color of the trip path. Should return string value presenting color of the trip path. -In case no data is returned, color value from **Path color** settings field will be used. +In case no data is returned, color value from **Color** settings field will be used.
    -##### Examples - -* Calculate color depending on `temperature` telemetry value for `colorpin` device type: - -```javascript -var type = data['Type']; -if (type == 'colorpin') { - var temperature = data['temperature']; - if (typeof temperature !== undefined) { - var percent = (temperature + 60)/120 * 100; - return tinycolor.mix('blue', 'red', percent).toHexString(); - } - return 'blue'; -} -{:copy-code} -``` - -
    -
    +{% include widget/lib/map/color_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_color_fn.md index de092704c3..be95f136ad 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_color_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_color_fn.md @@ -3,7 +3,7 @@

    -*function (data, dsData, dsIndex): string* +*function (data, dsData): string* A JavaScript function used to compute color of the trip path point. @@ -17,27 +17,8 @@ A JavaScript function used to compute color of the trip path point. Should return string value presenting color of the trip path point. -In case no data is returned, color value from **Point color** settings field will be used. +In case no data is returned, color value from **Color** settings field will be used.
    -##### Examples - -* Calculate color depending on `temperature` telemetry value for `colorpin` device type: - -```javascript -var type = data['Type']; -if (type == 'colorpin') { - var temperature = data['temperature']; - if (typeof temperature !== undefined) { - var percent = (temperature + 60)/120 * 100; - return tinycolor.mix('blue', 'red', percent).toHexString(); - } - return 'blue'; -} -{:copy-code} -``` - -
    -
    - +{% include widget/lib/map/color_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_tooltip_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_tooltip_fn.md new file mode 100644 index 0000000000..4ce996626a --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_tooltip_fn.md @@ -0,0 +1,22 @@ +#### Path point tooltip function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute text or HTML code to be displayed in the trip path point tooltip. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting text or HTML for the tooltip. + +
    + +{% include widget/lib/map/tooltip_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_fill_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_fill_color_fn.md new file mode 100644 index 0000000000..ef1ca8c54c --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_fill_color_fn.md @@ -0,0 +1,24 @@ +#### Polygon fill color function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute fill color of the polygon. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting fill color of the polygon. + +In case no data is returned, color value from **Color** settings field will be used. + +
    + +{% include widget/lib/map/color_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_label_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_label_fn.md new file mode 100644 index 0000000000..00bc0ee50b --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_label_fn.md @@ -0,0 +1,22 @@ +#### Polygon label function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute text or HTML code of the polygon label. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting text or HTML of the polygon label. + +
    + +{% include widget/lib/map/label_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_stroke_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_stroke_color_fn.md new file mode 100644 index 0000000000..4419ab991c --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_stroke_color_fn.md @@ -0,0 +1,24 @@ +#### Polygon stroke color function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute stroke color of the polygon. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting stroke color of the polygon. + +In case no data is returned, color value from **Color** settings field will be used. + +
    + +{% include widget/lib/map/color_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_tooltip_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_tooltip_fn.md index 86de41c185..9c3a5c8e5e 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_tooltip_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_tooltip_fn.md @@ -3,7 +3,7 @@

    -*function (data, dsData, dsIndex): string* +*function (data, dsData): string* A JavaScript function used to compute text or HTML code to be displayed in the polygon tooltip. @@ -15,26 +15,8 @@ A JavaScript function used to compute text or HTML code to be displayed in the p **Returns:** -Should return string value presenting text or HTML for the polygon tooltip. +Should return string value presenting text or HTML for the tooltip.
    -##### Examples - -* Display details with corresponding telemetry data for `energy meter` or `thermostat` device types: - -```javascript -var deviceType = data['Type']; -if (typeof deviceType !== undefined) { - if (deviceType == "energy meter") { - return '${entityName}
    Energy: ${energy:2} kWt
    '; - } else if (deviceType == "thermostat") { - return '${entityName}
    Temperature: ${temperature:2} °C
    '; - } -} -return data.entityName; -{:copy-code} -``` - -
    -
    +{% include widget/lib/map/tooltip_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/position_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/position_fn.md index 5687e4fe84..f85c691cdd 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/position_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/position_fn.md @@ -3,7 +3,7 @@

    -*function (origXPos, origYPos, data, dsData, dsIndex, aspect): {x: number, y: number}* +*function (origXPos, origYPos, data, dsData, aspect): {x: number, y: number}* A JavaScript function used to convert original relative x, y coordinates of the marker. @@ -22,8 +22,8 @@ Should return position data having the following structure: ```typescript { - x: number, - y: number + x: number; + y: number; } ``` diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn.md index d4d97d99b6..61fb00e7fd 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn.md @@ -3,9 +3,9 @@

    -*function (data, dsData, dsIndex): string* +*function (data, dsData): string* -A JavaScript function used to compute text or HTML code to be displayed in the marker, point or polygon tooltip. +A JavaScript function used to compute text or HTML code to be displayed in the marker tooltip. **Parameters:** @@ -19,22 +19,4 @@ Should return string value presenting text or HTML for the tooltip.
    -##### Examples - -* Display details with corresponding telemetry data for `thermostat` device type: - -```javascript -var deviceType = data['Type']; -if (typeof deviceType !== undefined) { - if (deviceType == "energy meter") { - return '${entityName}
    Energy: ${energy:2} kWt
    '; - } else if (deviceType == "thermometer") { - return '${entityName}
    Temperature: ${temperature:2} °C
    '; - } -} -return data.entityName; -{:copy-code} -``` - -
    -
    +{% include widget/lib/map/tooltip_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn_examples.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn_examples.md new file mode 100644 index 0000000000..34f8e06e0f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn_examples.md @@ -0,0 +1,19 @@ +##### Examples + +* Display details with corresponding telemetry data for `energy meter` or `thermometer` device types: + +```javascript +var deviceType = data.Type; +if (typeof deviceType !== undefined) { + if (deviceType == "energy meter") { + return '${entityName}
    Energy: ${energy:2} kWt
    '; + } else if (deviceType == "thermometer") { + return '${entityName}
    Temperature: ${temperature:2} °C
    '; + } +} +return data.entityName; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/trip_point_as_anchor_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/trip_point_as_anchor_fn.md index e938532b78..167fb0a123 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/trip_point_as_anchor_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/trip_point_as_anchor_fn.md @@ -1,9 +1,9 @@ -#### Point as anchor function +#### Location snap filter function

    -*function (data, dsData, dsIndex): boolean* +*function (data, dsData): boolean* A JavaScript function evaluating whether to use trip point as time anchor used in time selector. 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 67b406fc40..ea072f3151 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -65,6 +65,7 @@ "next-with-label": "Next: {{label}}", "read-more": "Read more", "hide": "Hide", + "test": "Test", "done": "Done", "print": "Print", "restore": "Restore", @@ -996,6 +997,7 @@ "failures": "Failures", "entity": "entity", "rule-node": "rule node", + "calculated-field": "calculated field", "hint": { "main": "All node debug messages rate limited with:", "main-limited": "All {{entity}} debug messages will be rate-limited, with a maximum of {{msg}} messages allowed per {{time}}.", @@ -1003,6 +1005,79 @@ "all-messages": "Save all debug events during time limit." } }, + "calculated-fields": { + "expression": "Expression", + "no-found": "No calculated fields found", + "list": "{ count, plural, =1 {One calculated field} other {List of # calculated fields} }", + "selected-fields": "{ count, plural, =1 {1 calculated field} other {# calculated fields} } selected", + "type": { + "simple": "Simple", + "script": "Script" + }, + "arguments": "Arguments", + "decimals-by-default": "Decimals by default", + "debugging": "Calculated field debugging", + "argument-name": "Argument name", + "datasource": "Datasource", + "add-argument": "Add argument", + "test-script-function": "Test script function", + "no-arguments": "No arguments configured", + "argument-settings": "Argument settings", + "argument-current": "Current entity", + "argument-current-tenant": "Current tenant", + "argument-device": "Device", + "argument-asset": "Asset", + "argument-customer": "Customer", + "argument-tenant": "Current tenant", + "argument-type": "Argument type", + "see-debug-events": "See debug events", + "attribute": "Attribute", + "copy-argument-name": "Copy argument name", + "timeseries-key": "Time series key", + "device-name": "Device name", + "latest-telemetry": "Latest telemetry", + "rolling": "Time series rolling", + "attribute-scope": "Attribute scope", + "server-attributes": "Server attributes", + "client-attributes": "Client attributes", + "shared-attributes": "Shared attributes", + "attribute-key": "Attribute key", + "default-value": "Default value", + "limit": "Max values", + "time-window": "Time window", + "customer-name": "Customer name", + "asset-name": "Asset name", + "timeseries": "Time series", + "output": "Output", + "create": "Create new calculated field", + "file": "Calculated field file", + "invalid-file-error": "Invalid file format. Please make sure the file is a valid JSON file.", + "import": "Import calculated field", + "export": "Export calculated field", + "export-failed-error": "Unable to export calculated field: {{error}}", + "output-type": "Output type", + "delete-title": "Are you sure you want to delete the calculated field '{{title}}'?", + "delete-text": "Be careful, after the confirmation the calculated field and all related data will become unrecoverable.", + "delete-multiple-title": "Are you sure you want to delete { count, plural, =1 {1 calculated field} other {# calculated fields} }?", + "delete-multiple-text": "Be careful, after the confirmation all selected calculated fields will be removed and all related data will become unrecoverable.", + "hint": { + "arguments-simple-with-rolling": "Simple type calculated field should not contain keys with time series rolling type.", + "arguments-empty": "Arguments should not be empty.", + "expression-required": "Expression is required.", + "expression-invalid": "Expression is invalid", + "expression-max-length": "Expression length should be less than 255 characters.", + "argument-name-required": "Argument name is required.", + "argument-name-pattern": "Argument name is invalid.", + "argument-name-duplicate": "Argument with such name already exists.", + "argument-name-max-length": "Argument name should be less than 256 characters.", + "argument-name-ctx": "Argument name 'ctx' is reserved and cannot be used.", + "argument-type-required": "Argument type is required.", + "max-args": "Maximum number of arguments reached.", + "decimals-range": "Decimals by default should be a number between 0 and 15.", + "expression": "Default expression demonstrates how to transform a temperature from Fahrenheit to Celsius.", + "arguments-entity-not-found": "Argument target entity not found." + } + }, "confirm-on-exit": { "message": "You have unsaved changes. Are you sure you want to leave this page?", "html-message": "You have unsaved changes.
    Are you sure you want to leave this page?", @@ -1027,8 +1102,13 @@ "city-max-length": "Specified city should be less than 256" }, "common": { + "name": "Name", + "type": "Type", + "general": "General", "username": "Username", "password": "Password", + "data": "Data", + "timestamp": "Timestamp", "enter-username": "Enter username", "enter-password": "Enter password", "enter-search": "Enter search", @@ -1038,8 +1118,27 @@ "proceed": "Proceed", "open-details-page": "Open details page", "not-found": "Not found", + "value": "Value", "documentation": "Documentation", - "time-left": "{{time}} left" + "time-left": "{{time}} left", + "output": "Output", + "test-function": "Test function", + "test-with-this-message": "{{test}} with this message", + "suffix": { + "s": "s", + "ms": "ms" + }, + "hint": { + "name-required": "Name is required.", + "name-pattern": "Name is invalid.", + "name-max-length": "Name should be less than 256 characters.", + "title-required": "Title is required.", + "title-pattern": "Title is invalid.", + "title-max-length": "Title should be less than 256 characters.", + "key-required": "Key is required.", + "key-pattern": "Key is invalid.", + "key-max-length": "Key should be less than 256 characters." + } }, "content-type": { "json": "Json", @@ -1349,6 +1448,7 @@ "general": "General", "advanced": "Advanced", "key": "Key", + "keys": "Keys", "label": "Label", "color": "Color", "units": "Special symbol to show next to value", @@ -2336,6 +2436,7 @@ "alias-required": "Entity alias is required.", "remove-alias": "Remove entity alias", "add-alias": "Add entity alias", + "edit-alias": "Edit entity alias", "entity-list": "Entity list", "entity-type": "Entity type", "entity-types": "Entity types", @@ -2431,6 +2532,8 @@ "type-current-tenant": "Current Tenant", "type-current-user": "Current User", "type-current-user-owner": "Current User Owner", + "type-calculated-field": "Calculated field", + "type-calculated-fields": "Calculated fields", "type-widgets-bundle": "Widgets bundle", "type-widgets-bundles": "Widgets bundles", "list-of-widgets-bundles": "{ count, plural, =1 {One widgets bundle} other {List of # widget bundles} }", @@ -2631,6 +2734,9 @@ "type-stats": "Statistics", "type-debug-rule-node": "Debug", "type-debug-rule-chain": "Debug", + "type-debug-calculated-field": "Debug", + "arguments": "Arguments", + "result": "Result", "no-events-prompt": "No events found", "error": "Error", "alarm": "Alarm", @@ -3221,6 +3327,7 @@ "top-fluid-color": "Top fluid color", "bottom-fluid-color": "Bottom fluid color", "display": "Display", + "display-format": "Display format", "value": "Value", "decimals": "Decimals", "units": "Units", @@ -3264,6 +3371,8 @@ "left-bottom-connector": "Left bottom connector", "top-left-connector": "Top left connector", "top-right-connector": "Top right connector", + "top-connector": "Top connector", + "bottom-connector": "Bottom connector", "running-color": "Running color", "stopped-color": "Stopped color", "stopped": "Stopped", @@ -3336,8 +3445,20 @@ "arrow-presence": "Arrow presence", "arrow-presence-hint": "Indicates whether arrow is present in connector.", "arrow-present": "Arrow present", - "arrow-direction": "Arrow direction", + "arrow-direction": "Arrow/Animation direction", "arrow-direction-hint": "Indicates flow direction.", + "animation-direction": "Flow animation direction", + "animation-direction-hint": "Indicates animation flow direction.", + "flow-animation": "Flow animation", + "flow-animation-hint": "Indicates whether animation is present in connector.", + "flow": "Flow", + "flow-style": "Flow style", + "flow-dash-cap": "Flow dash cap", + "dash-cap-butt": "Butt", + "dash-cap-round": "Round", + "dash-cap-square": "Square", + "dash": "Dash", + "gap": "Gap", "main-line": "Main line", "line": "Line", "line-color": "Line color", @@ -4739,10 +4860,6 @@ "min-buffer-memory-message": "Only 0 minimum buffer size is allowed.", "memory-buffer-size-range": "Memory buffer size must be between 0 and {{max}} KB", "acks": "Number of acknowledgments", - "key-serializer": "Key serializer", - "key-serializer-required": "Key serializer is required", - "value-serializer": "Value serializer", - "value-serializer-required": "Value serializer is required", "topic-arn-pattern": "Topic ARN pattern", "topic-arn-pattern-required": "Topic ARN pattern is required", "aws-access-key-id": "AWS Access Key ID", @@ -5162,7 +5279,25 @@ }, "time-series": "Time series", "latest": "Latest values", - "web-sockets": "WebSockets" + "web-sockets": "WebSockets", + "calculated-fields": "Calculated fields" + }, + "save-attribute": { + "processing-settings": "Processing settings", + "processing-settings-hint": "Define how incoming messages are processed. In Basic mode, select a preconfigured processing strategy or enable only WebSocket updates. Advanced mode allows you to select individual processing strategies for each action.", + "advanced-settings-hint": "Be cautious when configuring processing strategies. Certain combinations can lead to unexpected behavior.", + "strategy": "Strategy", + "deduplication-interval": "Deduplication interval", + "deduplication-interval-required": "Deduplication interval is required", + "deduplication-interval-min-max-range": "Deduplication interval should be at least 1 second and at most 1 day", + "scope": "Scope", + "strategy-type": { + "every-message": "On every message", + "skip": "Skip", + "deduplicate": "Deduplicate", + "web-sockets-only": "WebSockets only" + }, + "attributes": "Attributes" }, "key-val": { "key": "Key", @@ -5385,6 +5520,7 @@ "entities": "Entities", "rule-engine": "Rule Engine", "time-to-live": "Time-to-live", + "calculated-fields": "Calculated fields", "alarms-and-notifications": "Alarms and notifications", "ota-files-in-bytes": "Files", "ws-title": "WS", @@ -5437,6 +5573,21 @@ "tenant-entity-import-rate-limit": "Entity version load", "tenant-notification-request-rate-limit": "Notification requests", "tenant-notification-requests-per-rule-rate-limit": "Notification requests per notification rule", + "max-calculated-fields": "Calculated fields per entity maximum number", + "max-calculated-fields-range": "Calculated fields per entity maximum number can't be negative", + "max-calculated-fields-required": "Calculated fields per entity maximum number is required", + "max-data-points-per-rolling-arg": "Max data points number in rolling arguments", + "max-data-points-per-rolling-arg-range": "Max data points number in rolling arguments can't be negative", + "max-data-points-per-rolling-arg-required": "Max data points number in rolling arguments is required", + "max-arguments-per-cf": "Arguments per calculated field max number", + "max-arguments-per-cf-range": "Arguments per calculated field max number can't be negative", + "max-arguments-per-cf-required": "Arguments per calculated field max number is required", + "max-state-size": "State maximum size in KB", + "max-state-size-range": "State maximum size in KB can't be negative", + "max-state-size-required": "State maximum size in KB is required", + "max-value-argument-size": "Single value argument maximum size in KB", + "max-value-argument-size-range": "Single value argument maximum size in KB can't be negative", + "max-value-argument-size-required": "Single value argument maximum size in KB is required", "max-transport-messages": "Transport messages maximum number", "max-transport-messages-required": "Transport messages maximum number is required.", "max-transport-messages-range": "Transport messages maximum number can't be negative", @@ -5508,6 +5659,8 @@ "advanced-settings": "Advanced settings", "edit-limit": "Edit limit", "but-less-than": "but less than", + "calculated-field-debug-event-rate-limit": "Calculated field debug events", + "edit-calculated-field-debug-event-rate-limit": "Edit calculated field debug events rate limits", "edit-transport-tenant-msg-title": "Edit transport tenant messages rate limits", "edit-transport-tenant-telemetry-msg-title": "Edit transport tenant telemetry messages rate limits", "edit-transport-tenant-telemetry-data-points-title": "Edit transport tenant telemetry data points rate limits", @@ -6206,6 +6359,7 @@ "export-relations": "Export relations", "export-attributes": "Export attributes", "export-credentials": "Export credentials", + "export-calculated-fields": "Export calculated fields", "entity-versions": "Entity versions", "versions": "Versions", "created-time": "Created time", @@ -6222,6 +6376,7 @@ "load-relations": "Load relations", "load-attributes": "Load attributes", "load-credentials": "Load credentials", + "load-calculated-fields": "Load calculated fields", "compare-with-current": "Compare with current", "diff-entity-with-version": "Diff with entity version '{{versionName}}'", "previous-difference": "Previous Difference", @@ -6420,6 +6575,7 @@ "URL": "URL", "url-required": "URL is required.", "mobile": { + "device-provision": "Device provision", "action-type": "Mobile action type", "select-action-type": "Select mobile action type", "action-type-required": "Mobile action type is required", @@ -6433,7 +6589,15 @@ "take-screenshot": "Take screenshot" }, "custom-action-function": "Custom action function", - "custom-pretty-function": "Custom action (with HTML template) function" + "custom-pretty-function": "Custom action (with HTML template) function", + "map-item-type": "Map item type", + "map-item": { + "marker": "Marker", + "polygon": "Polygon", + "rectangle": "Rectangle", + "circle": "Circle" + }, + "place-map-item": "Place map item" }, "widgets-bundle": { "current": "Current bundle", @@ -6541,6 +6705,22 @@ "action-name-required": "Action name is required.", "action-name-not-unique": "Another action with the same name already exists.\nAction name should be unique within the same action source.", "action-icon": "Icon", + "header-button": { + "button-settings": "Button settings", + "button-type": "Button type", + "button-type-basic": "Basic", + "button-type-raised": "Raised", + "button-type-stroked": "Stroked", + "button-type-flat": "Flat", + "button-type-icon": "Icon", + "button-type-mini-fab": "FAB", + "colors": "Colors", + "color": "Color", + "background": "Background", + "border": "Border", + "advanced-button-style": "Advanced button style", + "button-style": "Button style" + }, "show-hide-action-using-function": "Show/hide action using function", "show-action-function": "Show action function", "action-type": "Type", @@ -7680,6 +7860,304 @@ "max-value": "Maximum value" }, "maps": { + "map-type": { + "type": "Map type", + "map": "Map", + "image": "Image" + }, + "image": { + "image-source": "Image source", + "image-source-image": "Image", + "image-source-entity-key": "Entity key", + "source-entity-alias": "Source entity alias", + "image-url-key": "Image URL key", + "image-url-key-required": "Image URL key is required" + }, + "control": { + "map-controls": "Map controls", + "position": "Position", + "position-topleft": "Top-left", + "position-topright": "Top-right", + "position-bottomleft": "Bottom-left", + "position-bottomright": "Bottom-right", + "zoom-actions": "Zoom actions", + "zoom-scroll": "Scroll", + "zoom-double-click": "Double click", + "zoom-control-buttons": "Control buttons", + "scale": "Scale", + "scale-metric": "Metric", + "scale-imperial": "Imperial", + "switch-to-drag-mode-using-button": "Switch to drag mode using button" + }, + "timeline": { + "control-panel": "Timeline control panel", + "time-step": "Time step", + "speed-options": "Speed options", + "timestamp": "Timestamp", + "snap-to-real-location": "Snap to real location", + "location-snap-filter-function": "Location snap filter function", + "no-trips-data-available": "No trips data available" + }, + "map-action": { + "map-action-buttons": "Map action buttons", + "label": "Label", + "icon": "Icon", + "color": "Color", + "action": "Action", + "add-button": "Add button", + "no-action-buttons-configured": "No action buttons configured", + "remove-action-button": "Remove action button", + "map-action-button": "Map action button", + "button-requires": "Button requires label or icon" + }, + "common": { + "common-map-settings": "Common map settings", + "fit-map-bounds": "Fit map bounds to cover all markers", + "default-map-center-position": "Default map center position", + "default-map-zoom-level": "Default map zoom level", + "entities-limit": "Limit of entities to load" + }, + "layer": { + "label": "Label", + "layer": "Layer", + "layers": "Layers", + "map-layers": "Map layers", + "add-layer": "Add layer", + "layer-settings": "Layer settings", + "remove-layer": "Remove layer", + "no-layers": "No layers configured", + "roadmap": "Roadmap", + "satellite": "Satellite", + "hybrid": "Hybrid", + "reference": { + "reference-layer": "Reference layer", + "no-layer": "No layer", + "openstreetmap-hybrid": "OpenStreetMap Hybrid", + "world-edition-hybrid": "World Edition Hybrid", + "enhanced-contrast-hybrid": "Enhanced Contrast Hybrid" + }, + "provider": { + "provider": "Provider", + "openstreet": { + "title": "OpenStreet", + "mapnik": "Mapnik", + "hot": "HOT", + "esri-street": "WorldStreetMap", + "esri-topo": "WorldTopoMap", + "esri-imagery": "WorldImagery", + "cartodb-positron": "Positron", + "cartodb-dark-matter": "DarkMatter" + }, + "google": { + "title": "Google", + "roadmap": "Roadmap", + "satellite": "Satellite", + "hybrid": "Hybrid", + "terrain": "Terrain" + }, + "here": { + "title": "HERE", + "normal-day": "Normal day", + "normal-night": "Normal night", + "hybrid-day": "Hybrid day", + "terrain-day": "Terrain day" + }, + "tencent": { + "title": "Tencent", + "normal": "Normal", + "satellite": "Satellite", + "terrain": "Terrain" + }, + "custom": { + "title": "Custom", + "tile-url": "Tile URL" + } + }, + "credentials": { + "credentials": "Credentials", + "api-key": "API Key" + } + }, + "overlays": { + "overlays": "Overlays", + "trips": "Trips", + "markers": "Markers", + "polygons": "Polygons", + "circles": "Circles" + }, + "data-layer": { + "source": "Source", + "additional-data-keys": "Additional data keys", + "additional-datasources": "Additional datasources", + "data-keys": "Data keys", + "add-datasource": "Add datasource", + "no-datasources": "No datasources configured", + "remove-datasource": "Remove datasource", + "behavior": "Behavior", + "on-click": "On click", + "on-click-hint": "Action invoked when user clicks on the map item.", + "groups": "Groups", + "color": "Color", + "fill-color": "Fill color", + "stroke": "Stroke", + "color-settings": "Color settings", + "color-type-constant": "Constant", + "color-type-range": "Range", + "color-type-function": "Function", + "color-range-source-key": "Color range source key", + "color-range-source-key-required": "Color range source key is required", + "color-range": "Color range", + "color-function": "Color function", + "label": "Label", + "tooltip": "Tooltip", + "pattern-type-pattern": "Pattern", + "pattern-type-function": "Function", + "label-pattern": "Label (pattern examples: '${entityName}', '${entityName}: (Text ${keyName} units.)' )", + "label-function": "Label function", + "tooltip-pattern": "Tooltip (for ex. 'Text ${keyName} units.' or Link text')", + "tooltip-function": "Tooltip function", + "tooltip-trigger": "Tooltip trigger", + "tooltip-trigger-click": "Show tooltip on click", + "tooltip-trigger-hover": "Show tooltip on hover", + "auto-close-tooltips": "Auto-close tooltips", + "tooltip-offset": "Tooltip offset", + "tooltip-offset-horizontal": "Horizontal", + "tooltip-offset-vertical": "Vertical", + "tooltip-tag-actions": "Tag actions", + "add-tooltip-tag-action": "Add tag action", + "edit-tooltip-tag-action": "Edit tag action", + "remove-tooltip-tag-action": "Remove tag action", + "action-add": "Add", + "action-edit": "Edit", + "action-move": "Move", + "action-remove": "Remove", + "edit-instruments": "Instruments", + "persist-location-attribute-scope": "Scope of the attribute to persist location", + "enable-snapping": "Enable snapping to other vertices for precision drawing", + "drag-drop-mode": "Drag-drop mode", + "trip": { + "no-trips": "No trips configured", + "add-trip": "Add trip", + "trip-configuration": "Trip configuration", + "remove-trip": "Remove trip" + }, + "marker": { + "marker": "Marker", + "latitude-key": "Latitude key", + "longitude-key": "Longitude key", + "x-pos-key": "X position key", + "y-pos-key": "Y position key", + "latitude-key-required": "Latitude key required", + "longitude-key-required": "Longitude key required", + "x-pos-key-required": "X position key required", + "y-pos-key-required": "Y position key required", + "no-markers": "No markers configured", + "add-marker": "Add marker", + "marker-configuration": "Marker configuration", + "remove-marker": "Remove marker", + "marker-type": "Marker type", + "marker-type-shape": "Shape", + "marker-type-icon": "Icon", + "marker-type-image": "Image", + "shape": "Shape", + "icon": "Icon", + "image": "Image", + "marker-shapes": "Marker shapes", + "marker-icon": "Marker icon", + "marker-appearance": "Marker appearance", + "marker-image": "Marker image", + "marker-image-type-image": "Image", + "marker-image-type-function": "Function", + "custom-marker-image-size": "Custom marker image size", + "marker-image-function": "Marker image function", + "marker-images": "Marker images", + "marker-offset": "Marker offset", + "offset-horizontal": "Horizontal", + "offset-vertical": "Vertical", + "rotate-marker": "Rotate marker", + "offset-angle": "Offset angle", + "position-conversion": "Position conversion", + "position-conversion-function": "Position conversion function, should return x,y coordinates as double from 0 to 1 each", + "clustering": { + "use-map-markers-clustering": "Use map markers clustering", + "zoom-on-cluster-click": "Zoom when clicking on a cluster", + "max-zoom": "The maximum zoom level when a marker can be part of a cluster (0 - 18)", + "max-radius": "Maximum radius that a cluster will cover", + "zoom-animation": "Animation on markers when zooming", + "bounds-on-cluster-mouse-over": "Bounds of markers when mouse over a cluster", + "spiderfy-max-zoom-level": "Spiderfy at the max zoom level (to see all cluster markers)", + "load-optimization": "Load optimization", + "chunked-load": "Use chunks for adding markers so that the page does not freeze", + "lazy-load": "Use lazy load for adding markers", + "use-cluster-marker-color-function": "Use cluster markers color function", + "marker-color-function": "Marker color function" + }, + "edit": "Edit marker", + "remove-marker-for": "Remove marker for '{{entityName}}'", + "place-marker": "Place marker", + "place-marker-hint": "Click to place marker", + "place-marker-hint-with-entity": "Click to place '{{entityName}}' entity" + }, + "path": { + "path": "Path", + "path-decorator": "Path decorator", + "decorator-symbol": "Decorator symbol", + "decorator-symbol-arrow-head": "Arrow", + "decorator-symbol-dash": "Dash", + "decorator-arrangement": "Decorator arrangement", + "decorator-offset": "Start", + "decorator-end-offset": "End", + "decorator-repeat": "Repeat" + }, + "points": { + "points": "Points", + "point-tooltip": "Point tooltip" + }, + "polygon": { + "polygon-key": "Polygon key", + "polygon-key-required": "Polygon key required", + "no-polygons": "No polygons configured", + "add-polygon": "Add polygon", + "polygon-configuration": "Polygon configuration", + "remove-polygon": "Remove polygon", + "edit": "Edit polygon", + "remove-polygon-for": "Remove polygon for '{{entityName}}'", + "cut": "Cut polygon area", + "rotate": "Rotate polygon", + "draw-rectangle": "Draw rectangle", + "draw-polygon": "Draw polygon", + "polygon-place-first-point-cut-hint": "Click to place first point", + "continue-polygon-cut-hint": "Click to continue drawing", + "finish-polygon-cut-hint": "Click first marker to finish and save", + "polygon-place-first-point-hint": "Polygon: click to place first point", + "polygon-place-first-point-hint-with-entity": "Polygon for '{{entityName}}': click to place first point", + "continue-polygon-hint": "Polygon: click to continue drawing", + "continue-polygon-hint-with-entity": "Polygon for '{{entityName}}': click to continue drawing", + "finish-polygon-hint": "Polygon: click first marker to finish drawing", + "finish-polygon-hint-with-entity": "Polygon for '{{entityName}}': click first marker to finish and save", + "rectangle-place-first-point-hint": "Rectangle: click to place first point", + "rectangle-place-first-point-hint-with-entity": "Rectangle for '{{entityName}}': click to place first point", + "finish-rectangle-hint": "Rectangle: click to finish drawing", + "finish-rectangle-hint-with-entity": "Rectangle for '{{entityName}}': click to finish and save" + }, + "circle": { + "circle-key": "Circle key", + "circle-key-required": "Circle key required", + "no-circles": "No circles configured", + "add-circle": "Add circle", + "circle-configuration": "Circle configuration", + "remove-circle": "Remove circle", + "edit": "Edit circle", + "remove-circle-for": "Remove circle for '{{entityName}}'", + "draw-circle": "Draw circle", + "place-circle-center-hint-with-entity": "Circle for '{{entityName}}': click to place circle center", + "place-circle-center-hint": "Circle: click to place circle center", + "finish-circle-hint-with-entity": "Circle for '{{entityName}}': click to finish and save circle", + "finish-circle-hint": "Circle: click to finish drawing" + }, + "select-entity": "Select entity", + "select-entity-hint": "Hint: after selection click at the map to set position" + }, "select-entity": "Select entity", "select-entity-hint": "Hint: after selection click at the map to set position", "tooltips": { @@ -8120,6 +8598,11 @@ "display-timestamp": "Timestamp", "display-pagination": "Display pagination", "default-page-size": "Default page size", + "page-step-settings": "Page step settings", + "page-step-count": "Number of steps", + "page-step-increment": "Step increment", + "page-step-count-format-message": "Should be an integer value, in the range from 1 to 100.", + "page-step-increment-format-message": "Should be an integer value, greater or equal to 1.", "use-entity-label-tab-name": "Use entity label in tab name", "hide-empty-lines": "Hide empty lines", "row-style": "Row style", @@ -8173,7 +8656,8 @@ "timeseries-column-error": "At least one time series column should be specified", "alarm-column-error": "At least one alarm column should be specified", "table-tabs": "Table tabs", - "show-cell-actions-menu-mobile": "Show cell actions dropdown menu in mobile mode" + "show-cell-actions-menu-mobile": "Show cell actions dropdown menu in mobile mode", + "disable-sorting": "Disable sorting" }, "latest-chart": { "total": "Total", diff --git a/ui-ngx/src/assets/map/enhanced_contrast_hybrid_reference_style.json b/ui-ngx/src/assets/map/enhanced_contrast_hybrid_reference_style.json new file mode 100644 index 0000000000..19077f3596 --- /dev/null +++ b/ui-ngx/src/assets/map/enhanced_contrast_hybrid_reference_style.json @@ -0,0 +1,7937 @@ +{ + "version": 8, + "sprite": "https://cdn.arcgis.com/sharing/rest/content/items/c8ec7c62427d484a81397aefb7e59c5f/resources/sprites/sprite", + "glyphs": "https://basemaps.arcgis.com/arcgis/rest/services/World_Basemap_v2/VectorTileServer/resources/fonts/{fontstack}/{range}.pbf", + "sources": { + "esri": { + "type": "vector", + "url": "https://basemaps.arcgis.com/arcgis/rest/services/World_Basemap_v2/VectorTileServer", + "attribution": "Sources: Esri, TomTom, Garmin, FAO, NOAA, USGS, © OpenStreetMap contributors, and the GIS User Community", + "tiles": [ + "https://basemaps.arcgis.com/arcgis/rest/services/World_Basemap_v2/VectorTileServer/tile/{z}/{y}/{x}.pbf" + ] + } + }, + "layers": [ + { + "id": "Boundary line/Admin0/casing_2", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 0 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 5, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#4d4a00", + "line-width": { + "base": 1.2, + "stops": [ + [ + 5, + 2 + ], + [ + 10, + 8 + ], + [ + 18, + 14 + ] + ] + } + } + }, + { + "id": "Boundary line/Admin1/casing_2", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 1 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 6, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "stops": [ + [ + 6, + "rgba(55,44,27,0.5)" + ], + [ + 9, + "rgba(55,44,27,0.5)" + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 5 + ], + [ + 10, + 7 + ], + [ + 14, + 10 + ], + [ + 17, + 12 + ] + ] + } + } + }, + { + "id": "Boundary line/Disputed admin5", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 11 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 16, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#9DA0A2", + "line-width": { + "base": 1.2, + "stops": [ + [ + 16, + 1.6 + ], + [ + 18, + 2.5 + ] + ] + }, + "line-dasharray": [ + 6, + 3 + ] + } + }, + { + "id": "Boundary line/Disputed admin4", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 10 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 16, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#9DA0A2", + "line-width": { + "base": 1.2, + "stops": [ + [ + 16, + 1.6 + ], + [ + 18, + 2.5 + ] + ] + }, + "line-dasharray": [ + 6, + 3 + ] + } + }, + { + "id": "Boundary line/Disputed admin3", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 9 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 16, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#9DA0A2", + "line-width": { + "base": 1.2, + "stops": [ + [ + 16, + 1.6 + ], + [ + 18, + 2.5 + ] + ] + }, + "line-dasharray": [ + 6, + 3 + ] + } + }, + { + "id": "Boundary line/Disputed admin2", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 8 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#9DA0A2", + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.3 + ], + [ + 14, + 1.6 + ], + [ + 18, + 2.5 + ] + ] + }, + "line-dasharray": [ + 6, + 3 + ] + } + }, + { + "id": "Boundary line/Disputed admin1", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 7 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 3, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#747922", + "line-width": { + "base": 1.2, + "stops": [ + [ + 3, + 1.3 + ], + [ + 14, + 1.6 + ], + [ + 18, + 2.5 + ] + ] + }, + "line-dasharray": [ + 6, + 3 + ] + } + }, + { + "id": "Boundary line/Disputed admin0", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 6 + ], + [ + "!in", + "Viz", + 3 + ], + [ + "!in", + "DisputeID", + 8, + 16, + 90, + 96, + 0 + ] + ], + "minzoom": 1, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#575900", + "line-width": { + "base": 1.2, + "stops": [ + [ + 0, + 0.75 + ], + [ + 6, + 1.2 + ], + [ + 14, + 1.6 + ], + [ + 18, + 2.5 + ] + ] + }, + "line-dasharray": [ + 6, + 3 + ] + } + }, + { + "id": "Boundary line/Admin2/casing", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 2 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#4d4a00", + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 2 + ], + [ + 14, + 3.5 + ], + [ + 18, + 4 + ] + ] + } + } + }, + { + "id": "Boundary line/Admin1/casing_1", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 1 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 6, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#4d4a00", + "line-width": { + "base": 1.2, + "stops": [ + [ + 8, + 1.2 + ], + [ + 12, + 3 + ], + [ + 18, + 3.25 + ] + ] + } + } + }, + { + "id": "Boundary line/Admin0/casing_1", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 0 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 5, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#4d4a00", + "line-width": { + "base": 1.2, + "stops": [ + [ + 8, + 2.2 + ], + [ + 12, + 3 + ], + [ + 14, + 3.25 + ], + [ + 18, + 6.5 + ] + ] + } + } + }, + { + "id": "Boundary line/Admin5", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 5 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 16, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#9C9C9C", + "line-width": { + "base": 1.2, + "stops": [ + [ + 16, + 1.33 + ], + [ + 18, + 2 + ] + ] + }, + "line-dasharray": [ + 2, + 2 + ] + } + }, + { + "id": "Boundary line/Admin4", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 4 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 16, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#9C9C9C", + "line-width": { + "base": 1.2, + "stops": [ + [ + 16, + 1.33 + ], + [ + 18, + 2 + ] + ] + }, + "line-dasharray": [ + 2, + 2 + ] + } + }, + { + "id": "Boundary line/Admin3", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 3 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 16, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#9C9C9C", + "line-width": { + "base": 1.2, + "stops": [ + [ + 16, + 1.33 + ], + [ + 18, + 2 + ] + ] + }, + "line-dasharray": [ + 2, + 2 + ] + } + }, + { + "id": "Boundary line/Admin2/line", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 2 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#bfbd6a", + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1 + ], + [ + 14, + 1.33 + ], + [ + 18, + 1.5 + ] + ] + }, + "line-dasharray": [ + 8, + 5.33 + ] + } + }, + { + "id": "Boundary line/Admin1/line", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 1 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 4, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1, + "stops": [ + [ + 10, + "#999755" + ], + [ + 11, + "#d9d678" + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 8, + 0.75 + ], + [ + 12, + 1.5 + ], + [ + 18, + 1.75 + ] + ] + } + } + }, + { + "id": "Boundary line/Admin0/line", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 0 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "stops": [ + [ + 1, + "#bfbd6a" + ], + [ + 5, + "#e6e150" + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 0, + 0.75 + ], + [ + 6, + 1.2 + ], + [ + 14, + 3 + ], + [ + 18, + 5 + ] + ] + } + } + }, + { + "id": "Ferry/label/Ferry", + "type": "symbol", + "source": "esri", + "source-layer": "Ferry/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 0 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 11, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.07, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-field": "{_name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#007cb1", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Water point/Stream or river", + "type": "symbol", + "source": "esri", + "source-layer": "Water point", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 9, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 9, + 10 + ], + [ + 15, + 11.5 + ] + ] + }, + "icon-size": 10, + "text-font": [ + "Ubuntu Medium Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 5, + "text-padding": 15, + "text-letter-spacing": 0.15, + "icon-allow-overlap": true, + "text-field": "{_name_global}", + "icon-image": "Water point" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,50,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Water point/Lake or reservoir", + "type": "symbol", + "source": "esri", + "source-layer": "Water point", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 9, + "layout": { + "text-size": { + "stops": [ + [ + 9, + 7 + ], + [ + 15, + 11.3 + ] + ] + }, + "text-font": [ + "Ubuntu Medium Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 5, + "text-padding": 15, + "text-letter-spacing": 0.01, + "icon-allow-overlap": true, + "text-field": "{_name_global}", + "icon-image": "Water point" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#007cb1", + "text-halo-width": 1.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "Water point/Bay or inlet", + "type": "symbol", + "source": "esri", + "source-layer": "Water point", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 9, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 9, + 10 + ], + [ + 15, + 13 + ] + ] + }, + "text-font": [ + "Ubuntu Bold Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 5, + "text-padding": 15, + "text-letter-spacing": 0.15, + "icon-allow-overlap": true, + "text-field": "{_name_global}", + "icon-image": "Water point" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#007cb1", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Water point/Sea or ocean", + "type": "symbol", + "source": "esri", + "source-layer": "Water point", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 9, + "layout": { + "text-size": 12, + "text-font": [ + "Ubuntu Bold Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": { + "stops": [ + [ + 15.5, + 7 + ], + [ + 15.6, + 9 + ] + ] + }, + "text-padding": 15, + "text-letter-spacing": 0.1, + "icon-allow-overlap": true, + "text-field": "{_name_global}", + "icon-image": "Water point" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#007cb1", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Water point/Canal or ditch", + "type": "symbol", + "source": "esri", + "source-layer": "Water point", + "filter": [ + "==", + "_label_class", + 4 + ], + "minzoom": 11, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 5, + "text-padding": 15, + "text-letter-spacing": 0.01, + "icon-allow-overlap": true, + "text-field": "{_name_global}", + "icon-image": "Water point" + }, + "paint": { + "text-color": "#c8edff", + "text-halo-color": "rgba(0,0,50,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Water point/Island", + "type": "symbol", + "source": "esri", + "source-layer": "Water point", + "filter": [ + "==", + "_label_class", + 7 + ], + "minzoom": 11, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 11, + 10 + ], + [ + 15, + 11 + ] + ] + }, + "icon-size": 10, + "text-font": [ + "Ubuntu Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 5, + "text-padding": 15, + "text-letter-spacing": 0.05, + "icon-allow-overlap": true, + "text-field": "{_name_global}", + "icon-image": "Water point" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,50,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "Water line/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Water line/label", + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.15, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-offset": [ + 0, + -0.5 + ], + "text-max-angle": 30, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#c8edff", + "text-halo-color": "rgba(0,0,50,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Water area/label/Canal or ditch", + "type": "symbol", + "source": "esri", + "source-layer": "Water area/label", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 11, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 5, + "text-letter-spacing": 0.01, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#c8edff", + "text-halo-color": "rgba(0,0,50,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Water area/label/Small river", + "type": "symbol", + "source": "esri", + "source-layer": "Water area/label", + "filter": [ + "==", + "_label_class", + 7 + ], + "minzoom": 11, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 5, + "text-letter-spacing": 0.15, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#c8edff", + "text-halo-color": "rgba(0,0,50,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Water area/label/Large river", + "type": "symbol", + "source": "esri", + "source-layer": "Water area/label", + "filter": [ + "==", + "_label_class", + 4 + ], + "minzoom": 11, + "layout": { + "text-size": 12, + "text-font": [ + "Ubuntu Medium Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.15, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#c8edff", + "text-halo-color": "rgba(0,0,50,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Water area/label/Small lake or reservoir", + "type": "symbol", + "source": "esri", + "source-layer": "Water area/label", + "filter": [ + "==", + "_label_class", + 6 + ], + "minzoom": 11, + "layout": { + "text-size": 11, + "text-font": [ + "Ubuntu Medium Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 5, + "text-letter-spacing": 0.01, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#c8edff", + "text-halo-color": "rgba(0,0,50,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Water area/label/Large lake or reservoir", + "type": "symbol", + "source": "esri", + "source-layer": "Water area/label", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 11, + "layout": { + "text-size": 12, + "text-font": [ + "Ubuntu Medium Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.01, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#c8edff", + "text-halo-color": "rgba(0,0,50,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Water area/label/Bay or inlet", + "type": "symbol", + "source": "esri", + "source-layer": "Water area/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 12, + "layout": { + "text-size": 12, + "text-font": [ + "Ubuntu Bold Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.15, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#c8edff", + "text-halo-color": "#007cb1", + "text-halo-width": 2.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "Water area/label/Small island", + "type": "symbol", + "source": "esri", + "source-layer": "Water area/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 11, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 5, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,50,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "Water area/label/Large island", + "type": "symbol", + "source": "esri", + "source-layer": "Water area/label", + "filter": [ + "==", + "_label_class", + 5 + ], + "minzoom": 11, + "layout": { + "text-size": 13, + "text-font": [ + "Ubuntu Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,50,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Water area large scale/label/River", + "type": "symbol", + "source": "esri", + "source-layer": "Water area large scale/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 7, + "maxzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{_name}" + }, + "paint": { + "text-color": "#c8edff", + "text-halo-color": "rgba(0,0,50,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Water area large scale/label/Lake or lake intermittent", + "type": "symbol", + "source": "esri", + "source-layer": "Water area large scale/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 7, + "maxzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 5, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name}" + }, + "paint": { + "text-color": "#c8edff", + "text-halo-color": "rgba(0,0,50,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Water area medium scale/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Water area medium scale/label", + "minzoom": 5, + "maxzoom": 7, + "layout": { + "text-size": 9.33, + "text-font": [ + "Ubuntu Medium Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 5, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name}" + }, + "paint": { + "text-color": "#c8edff", + "text-halo-color": "rgba(0,0,50,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Water area small scale/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Water area small scale/label", + "minzoom": 1, + "maxzoom": 5, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 5, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name}" + }, + "paint": { + "text-color": "#c8edff", + "text-halo-color": "rgba(0,0,50,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Marine area/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Marine area/label", + "minzoom": 10, + "layout": { + "text-size": 12, + "text-font": [ + "Ubuntu Bold Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#007cb1", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Marine waterbody/label/small", + "type": "symbol", + "source": "esri", + "source-layer": "Marine waterbody/label", + "filter": [ + "==", + "_label_class", + 4 + ], + "minzoom": 1, + "maxzoom": 10, + "layout": { + "text-size": 11, + "text-font": [ + "Ubuntu Bold Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 6, + "text-padding": 15, + "text-letter-spacing": 0.1, + "symbol-avoid-edges": true, + "text-field": "{_name}" + }, + "paint": { + "text-opacity": 0.8, + "text-color": "#ffffff", + "text-halo-color": "#007cb1", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Marine waterbody/label/medium", + "type": "symbol", + "source": "esri", + "source-layer": "Marine waterbody/label", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 1, + "maxzoom": 10, + "layout": { + "text-size": 12, + "text-font": [ + "Ubuntu Bold Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 6, + "text-padding": 15, + "text-letter-spacing": 0.1, + "symbol-avoid-edges": true, + "text-field": "{_name}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#007cb1", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Marine waterbody/label/large", + "type": "symbol", + "source": "esri", + "source-layer": "Marine waterbody/label", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 1, + "maxzoom": 10, + "layout": { + "text-size": 12, + "text-font": [ + "Ubuntu Medium Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 6, + "text-padding": 15, + "text-letter-spacing": 0.1, + "symbol-avoid-edges": true, + "text-field": "{_name}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#007cb1", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Marine waterbody/label/x large", + "type": "symbol", + "source": "esri", + "source-layer": "Marine waterbody/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 1, + "maxzoom": 10, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 3, + 11 + ], + [ + 5, + 12 + ], + [ + 8, + 16 + ] + ] + }, + "text-font": [ + "Ubuntu Bold Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 6, + "text-padding": 15, + "text-letter-spacing": 0.1, + "symbol-avoid-edges": true, + "text-field": "{_name}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#007cb1", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Marine waterbody/label/2x large", + "type": "symbol", + "source": "esri", + "source-layer": "Marine waterbody/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "maxzoom": 10, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 1, + 11 + ], + [ + 3, + 16 + ] + ] + }, + "text-font": [ + "Ubuntu Bold Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 6, + "text-padding": 15, + "text-letter-spacing": 0.1, + "symbol-avoid-edges": true, + "text-field": "{_name}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#007cb1", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Ferry/label/Rail ferry", + "type": "symbol", + "source": "esri", + "source-layer": "Ferry/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 1 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 14, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Light", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + -0.6 + ], + "text-field": "{_name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "Railroad/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Railroad/label", + "minzoom": 14, + "layout": { + "text-size": 11, + "text-font": [ + "Ubuntu Light", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 10, + "text-letter-spacing": 0.2, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-offset": [ + 0, + -0.6 + ], + "text-field": "{_name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "Trail or path/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Trail or path/label", + "minzoom": 15, + "layout": { + "text-size": 9, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{_name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000005", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Road tunnel/label/Pedestrian", + "type": "symbol", + "source": "esri", + "source-layer": "Road tunnel/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 6 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 15, + "layout": { + "text-size": 9.33, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{_name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000005", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Road tunnel/label/Local", + "type": "symbol", + "source": "esri", + "source-layer": "Road tunnel/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 5 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": { + "stops": [ + [ + 12, + 8 + ], + [ + 14, + 10.67 + ] + ] + }, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.09, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{_name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000005", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Road tunnel/label/Minor", + "type": "symbol", + "source": "esri", + "source-layer": "Road tunnel/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 4 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 13, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.09, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{_name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000005", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Road tunnel/label/Major, alt name", + "type": "symbol", + "source": "esri", + "source-layer": "Road tunnel/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 3 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.09, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{ _name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000005", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Road tunnel/label/Major", + "type": "symbol", + "source": "esri", + "source-layer": "Road tunnel/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 2 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.09, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{_name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000005", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Road tunnel/label/Freeway Motorway, alt name", + "type": "symbol", + "source": "esri", + "source-layer": "Road tunnel/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 1 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.09, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{ _name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000005", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Road tunnel/label/Highway", + "type": "symbol", + "source": "esri", + "source-layer": "Road tunnel/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 7 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.09, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{_name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000005", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Road tunnel/label/Freeway Motorway", + "type": "symbol", + "source": "esri", + "source-layer": "Road tunnel/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 0 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.09, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{_name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000005", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Road/label/Rectangle hexagon brown white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 72 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle hexagon brown white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Rectangle hexagon green white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 70 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle hexagon green white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Rectangle hexagon red white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 68 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle hexagon red white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Rectangle hexagon blue white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 66 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle hexagon blue white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Rectangle hexagon brown white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 71 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle hexagon brown white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Rectangle hexagon green white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 69 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 13, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle hexagon green white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Rectangle hexagon red white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 67 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle hexagon red white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Rectangle hexagon blue white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 65 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 11, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle hexagon blue white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Octagon green white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 74 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 11, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.02, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Octagon green white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Hexagon orange black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 64 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.03, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Hexagon orange black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Hexagon green white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 62 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.03, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Hexagon green white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Hexagon red white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 60 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.03, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Hexagon red white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Hexagon white black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 56 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.03, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Hexagon white black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Pentagon green yellow (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 54 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Pentagon green yellow/{_len}" + }, + "paint": { + "text-color": "#ffff96" + } + }, + { + "id": "Road/label/Pentagon green white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 52 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Pentagon green white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Pentagon yellow black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 50 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.02, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Pentagon yellow black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Pentagon blue white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 48 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.02, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Pentagon blue white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Pentagon white black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 46 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.3 + ], + "text-field": "{_name}", + "icon-image": "Road/Pentagon white black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Pentagon inverse white black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 44 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.3 + ], + "text-field": "{_name}", + "icon-image": "Road/Pentagon inverse white black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Rectangle green yellow (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 42 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 14, + "layout": { + "text-size": 10, + "icon-size": 1.2, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle green yellow/{_len}" + }, + "paint": { + "text-color": "#ffff96" + } + }, + { + "id": "Road/label/Rectangle green white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 40 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10.5, + "icon-size": 1.2, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.05, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle green white (Alt)/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Rectangle yellow black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 38 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 1.2, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.05, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle yellow black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Hexagon blue white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 58 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.03, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Hexagon blue white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Rectangle red white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 36 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10.5, + "icon-size": 1.2, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.05, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle red white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Rectangle blue white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 34 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10.5, + "icon-size": 1.2, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle blue white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Rectangle white black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 32 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 14, + "layout": { + "text-size": 10, + "icon-size": 1.3, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.05, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle white black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/V-shaped white black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 30 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/V-shaped white black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/U-shaped blue white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 28 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.02, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/U-shaped blue white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/U-shaped red white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 26 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/U-shaped red white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/U-shaped yellow black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 24 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.02, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/U-shaped yellow black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/U-shaped green leaf (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 22 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/U-shaped green leaf/{_len}" + }, + "paint": { + "text-color": "#000001", + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "Road/label/U-shaped white green (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 20 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/U-shaped white green/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/U-shaped white black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 18 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/U-shaped white black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Secondary Hwy red white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 16 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Secondary Hwy red white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Secondary Hwy green white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 14 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 1.1, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Secondary Hwy green white (Alt)/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Secondary Hwy white black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 12 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 14, + "layout": { + "text-size": 11, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Secondary Hwy white black (Alt)/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Shield white black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 10 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 11, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-field": "{_name}", + "icon-image": "Road/Shield white black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Shield blue white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 8 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 11, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-field": "{_name}", + "icon-image": "Road/Shield blue white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Octagon green white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 73 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.02, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Octagon green white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Hexagon orange black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 63 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.03, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Hexagon orange black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Hexagon green white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 61 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.03, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Hexagon green white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Hexagon red white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 59 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.03, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Hexagon red white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Hexagon white black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 55 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.03, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Hexagon white black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Pentagon green yellow", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 53 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Pentagon green yellow/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Pentagon green white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 51 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Pentagon green white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Pentagon yellow black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 49 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.02, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Pentagon yellow black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Pentagon blue white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 47 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.02, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Pentagon blue white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Pentagon white black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 45 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 9.5, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.3 + ], + "text-field": "{_name}", + "icon-image": "Road/Pentagon white black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Pentagon inverse white black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 43 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.3 + ], + "text-field": "{_name}", + "icon-image": "Road/Pentagon inverse white black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Rectangle green yellow", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 41 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 14, + "layout": { + "text-size": 10.5, + "icon-size": 1.3, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle green yellow/{_len}" + }, + "paint": { + "text-color": "#f5f547" + } + }, + { + "id": "Road/label/Rectangle green white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 39 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 13, + "layout": { + "text-size": 10.5, + "icon-size": 1.3, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.05, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle green white (Alt)/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Rectangle yellow black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 37 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 1.1, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.05, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle yellow black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Hexagon blue white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 57 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.03, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Hexagon blue white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Rectangle red white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 35 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10.5, + "icon-size": 1.3, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.05, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle red white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Rectangle blue white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 33 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10.5, + "icon-size": 1.3, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle blue white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Rectangle white black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 31 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10.5, + "icon-size": 1.25, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.05, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Rectangle white black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/V-shaped white black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 29 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/V-shaped white black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/U-shaped blue white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 27 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.02, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/U-shaped blue white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/U-shaped red white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 25 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/U-shaped red white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/U-shaped yellow black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 23 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-letter-spacing": 0.02, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/U-shaped yellow black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/U-shaped green leaf", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 21 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/U-shaped green leaf/{_len}" + }, + "paint": { + "text-color": "#000001", + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "Road/label/U-shaped white green", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 19 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/U-shaped white green/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/U-shaped white black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 17 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "icon-size": 0.9, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/U-shaped white black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Secondary Hwy red white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 15 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Secondary Hwy red white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Secondary Hwy green white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 13 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Secondary Hwy green white/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Secondary Hwy white black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 11 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 12, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name}", + "icon-image": "Road/Secondary Hwy white black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Shield white black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 9 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 11, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 30, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-field": "{_name}", + "icon-image": "Road/Shield white black/{_len}" + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "Road/label/Shield blue white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 7 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 12, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": { + "stops": [ + [ + 7, + 27 + ], + [ + 9, + 20 + ], + [ + 12, + 15 + ] + ] + }, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-field": "{_name}", + "icon-image": "Road/Shield blue white/{_len}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-width": 2 + } + }, + { + "id": "Pedestrian/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Pedestrian/label", + "minzoom": 15, + "layout": { + "text-size": 9.33, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Road/label/Pedestrian", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 6 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 15, + "layout": { + "text-size": 9, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{_name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000005", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Road/label/Local", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 5 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 14, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 3, + "text-letter-spacing": 0.09, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{_name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000005", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Road/label/Minor", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 4 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 13, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.09, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{_name_global}", + "text-padding": { + "stops": [ + [ + 10, + 20 + ], + [ + 18, + 2 + ] + ] + }, + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000005", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Road/label/Major, alt name", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 3 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.09, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{ _name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000005", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Road/label/Major", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 2 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.09, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{_name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000005", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Road/label/Freeway Motorway, alt name", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 1 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.09, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{ _name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000005", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Road/label/Highway", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 75 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.09, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{_name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000005", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Road/label/Freeway Motorway", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 0 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.09, + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-field": "{_name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000005", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Cemetery/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Cemetery/label", + "minzoom": 13, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Freight/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Freight/label", + "minzoom": 13, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Water and wastewater/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Water and wastewater/label", + "minzoom": 13, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Port/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Port/label", + "minzoom": 10, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Industry/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Industry/label", + "minzoom": 13, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Government/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Government/label", + "minzoom": 13, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Finance/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Finance/label", + "minzoom": 13, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Emergency/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Emergency/label", + "minzoom": 13, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Indigenous/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Indigenous/label", + "minzoom": 7, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Military/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Military/label", + "minzoom": 7, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 25, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Transportation/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation/label", + "minzoom": 13, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Golf course/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Golf course/label", + "minzoom": 11, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Zoo/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Zoo/label", + "minzoom": 11, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Retail/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Retail/label", + "minzoom": 13, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Landmark/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Landmark/label", + "minzoom": 13, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Openspace or forest/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Openspace or forest/label", + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 10, + 10 + ], + [ + 13, + 13 + ] + ] + }, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + }, + "minzoom": 10 + }, + { + "id": "Park or farming/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Park or farming/label", + "minzoom": 11, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 25, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Point of interest/Park", + "type": "symbol", + "source": "esri", + "source-layer": "Point of interest", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 9, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 9, + 10 + ], + [ + 13, + 13 + ] + ] + }, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.08, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Education/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Education/label", + "minzoom": 11, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Medical/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Medical/label", + "minzoom": 11, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Admin1 forest or park/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Admin1 forest or park/label", + "minzoom": 7, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 7, + 10 + ], + [ + 11, + 11 + ], + [ + 13, + 14 + ] + ] + }, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 25, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Admin0 forest or park/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Admin0 forest or park/label", + "minzoom": 7, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 7, + 10 + ], + [ + 9, + 11 + ], + [ + 13, + 14 + ] + ] + }, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 25, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Airport/label/Airport property", + "type": "symbol", + "source": "esri", + "source-layer": "Airport/label", + "minzoom": 9, + "layout": { + "text-size": 10.67, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 15, + "text-letter-spacing": 0.05, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 1.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "Exit/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Exit", + "minzoom": 15, + "layout": { + "text-size": 10.5, + "icon-size": 1.2, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "text-rotation-alignment": "viewport", + "icon-rotation-alignment": "viewport", + "symbol-avoid-edges": true, + "text-offset": [ + 0, + 0.1 + ], + "text-field": "{_name_global}", + "icon-image": "Exit/Default/{_len}" + }, + "paint": { + "text-color": "#000000", + "text-halo-color": "#008c63" + } + }, + { + "id": "Building/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Building/label", + "minzoom": 15, + "layout": { + "text-size": 10, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 20, + "text-letter-spacing": 0.08, + "symbol-avoid-edges": true, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 1.5 + } + }, + { + "id": "Point of interest/Bus station", + "type": "symbol", + "source": "esri", + "source-layer": "Point of interest", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 9, + "layout": { + "text-size": 10.67, + "icon-size": { + "base": 1, + "stops": [ + [ + 14, + 0.75 + ], + [ + 18, + 1.2 + ] + ] + }, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.04, + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom", + "text-offset": [ + 0, + -0.9 + ], + "text-field": "{_name_global}", + "icon-image": "Point of interest/Bus station", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 1.5 + } + }, + { + "id": "Point of interest/Rail station", + "type": "symbol", + "source": "esri", + "source-layer": "Point of interest", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 9, + "layout": { + "text-size": 10, + "icon-size": { + "base": 1, + "stops": [ + [ + 14, + 0.75 + ], + [ + 18, + 1.2 + ] + ] + }, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.05, + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom", + "text-offset": [ + 0, + -0.9 + ], + "text-field": "{_name_global}", + "icon-image": "Point of interest/Rail station", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "Admin2 area/label/small", + "type": "symbol", + "source": "esri", + "source-layer": "Admin2 area/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 10, + "maxzoom": 11, + "layout": { + "text-size": 11, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.5, + "symbol-avoid-edges": true, + "text-field": "{_name}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 1.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "Admin2 area/label/large", + "type": "symbol", + "source": "esri", + "source-layer": "Admin2 area/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 10, + "maxzoom": 11, + "layout": { + "text-size": 13, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.5, + "text-justify": "left", + "symbol-avoid-edges": true, + "text-field": "{_name}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 1.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "Admin1 area/label/x small", + "type": "symbol", + "source": "esri", + "source-layer": "Admin1 area/label", + "filter": [ + "==", + "_label_class", + 5 + ], + "minzoom": 4, + "maxzoom": 10, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 4, + 10 + ], + [ + 10, + 11 + ] + ] + }, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.3, + "symbol-avoid-edges": true, + "text-field": "{_name}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 1.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "Admin1 area/label/small", + "type": "symbol", + "source": "esri", + "source-layer": "Admin1 area/label", + "filter": [ + "==", + "_label_class", + 4 + ], + "minzoom": 4, + "maxzoom": 10, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 4, + 10 + ], + [ + 10, + 11 + ] + ] + }, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.3, + "symbol-avoid-edges": true, + "text-field": "{_name}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 1.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "Admin1 area/label/medium", + "type": "symbol", + "source": "esri", + "source-layer": "Admin1 area/label", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 4, + "maxzoom": 10, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 4, + 10 + ], + [ + 10, + 13 + ] + ] + }, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.3, + "symbol-avoid-edges": true, + "text-field": "{_name}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 1.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "Admin1 area/label/large", + "type": "symbol", + "source": "esri", + "source-layer": "Admin1 area/label", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 4, + "maxzoom": 10, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 4, + 10 + ], + [ + 10, + 13 + ] + ] + }, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.3, + "symbol-avoid-edges": true, + "text-field": "{_name}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 1.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "Admin1 area/label/x large", + "type": "symbol", + "source": "esri", + "source-layer": "Admin1 area/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 4, + "maxzoom": 10, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 4, + 10 + ], + [ + 10, + 16 + ] + ] + }, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-padding": 1, + "text-letter-spacing": 0.3, + "symbol-avoid-edges": true, + "text-field": "{_name}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 1.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "Admin1 area/label/2x large", + "type": "symbol", + "source": "esri", + "source-layer": "Admin1 area/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 3, + "maxzoom": 10, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 4, + 10 + ], + [ + 10, + 16 + ] + ] + }, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.3, + "symbol-avoid-edges": true, + "text-field": "{_name}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 1.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "Admin0 point/x small", + "type": "symbol", + "source": "esri", + "source-layer": "Admin0 point", + "filter": [ + "==", + "_label_class", + 5 + ], + "minzoom": 5, + "maxzoom": 10, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 5, + 10 + ], + [ + 10, + 13 + ] + ] + }, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.3, + "symbol-avoid-edges": true, + "text-field": "{_name}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 1.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "Admin0 point/small", + "type": "symbol", + "source": "esri", + "source-layer": "Admin0 point", + "filter": [ + "==", + "_label_class", + 4 + ], + "minzoom": 4, + "maxzoom": 10, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 4, + 10 + ], + [ + 10, + 14 + ] + ] + }, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.3, + "symbol-avoid-edges": true, + "text-field": "{_name}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 1.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "Admin0 point/medium", + "type": "symbol", + "source": "esri", + "source-layer": "Admin0 point", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 3, + 10 + ], + [ + 10, + 16 + ] + ] + }, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 6, + "text-letter-spacing": 0.3, + "symbol-avoid-edges": true, + "text-field": "{_name}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Admin0 point/large", + "type": "symbol", + "source": "esri", + "source-layer": "Admin0 point", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 2, + 10.5 + ], + [ + 10, + 16.5 + ] + ] + }, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.4, + "symbol-avoid-edges": true, + "text-field": "{_name}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 1.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "Admin0 point/x large", + "type": "symbol", + "source": "esri", + "source-layer": "Admin0 point", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 2, + 10.5 + ], + [ + 10, + 19 + ] + ] + }, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.4, + "symbol-avoid-edges": true, + "text-field": "{_name}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 1.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "Admin0 point/2x large", + "type": "symbol", + "source": "esri", + "source-layer": "Admin0 point", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 2, + 12 + ], + [ + 10, + 19 + ] + ] + }, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.4, + "symbol-avoid-edges": true, + "text-field": "{_name}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 1.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "Neighborhood", + "type": "symbol", + "source": "esri", + "source-layer": "Neighborhood", + "minzoom": 14, + "maxzoom": 18, + "layout": { + "text-size": { + "stops": [ + [ + 14, + 10 + ], + [ + 15, + 14 + ], + [ + 16, + 18 + ] + ] + }, + "text-font": [ + "Ubuntu Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "symbol-avoid-edges": true, + "text-field": "{_name_global}", + "icon-image": "Neighborhood" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City large scale/town small", + "type": "symbol", + "source": "esri", + "source-layer": "City large scale", + "filter": [ + "==", + "_label_class", + 5 + ], + "minzoom": 10, + "maxzoom": 17, + "layout": { + "text-size": { + "stops": [ + [ + 10, + 10 + ], + [ + 15, + 16 + ], + [ + 17, + 20 + ] + ] + }, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "symbol-avoid-edges": true, + "text-field": "{_name_global}", + "icon-image": "City large scale" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City large scale/town large", + "type": "symbol", + "source": "esri", + "source-layer": "City large scale", + "filter": [ + "==", + "_label_class", + 4 + ], + "minzoom": 10, + "maxzoom": 16, + "layout": { + "text-size": { + "stops": [ + [ + 10, + 10.5 + ], + [ + 15, + 18 + ], + [ + 16, + 20 + ] + ] + }, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "symbol-avoid-edges": true, + "text-field": "{_name_global}", + "icon-image": "City large scale" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City large scale/small", + "type": "symbol", + "source": "esri", + "source-layer": "City large scale", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 10, + "maxzoom": 15, + "layout": { + "text-size": { + "stops": [ + [ + 10, + 11 + ], + [ + 15, + 18 + ] + ] + }, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "symbol-avoid-edges": true, + "text-field": "{_name_global}", + "icon-image": "City large scale" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City large scale/medium", + "type": "symbol", + "source": "esri", + "source-layer": "City large scale", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 10, + "maxzoom": 15, + "layout": { + "text-size": { + "stops": [ + [ + 10, + 12 + ], + [ + 14, + 20 + ] + ] + }, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "symbol-avoid-edges": true, + "text-field": "{_name_global}", + "icon-image": "City large scale" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City large scale/large", + "type": "symbol", + "source": "esri", + "source-layer": "City large scale", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 10, + "maxzoom": 14, + "layout": { + "text-size": { + "stops": [ + [ + 10, + 13 + ], + [ + 14, + 22 + ] + ] + }, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "symbol-avoid-edges": true, + "text-field": "{_name_global}", + "icon-image": "City large scale" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City large scale/x large", + "type": "symbol", + "source": "esri", + "source-layer": "City large scale", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 10, + "maxzoom": 14, + "layout": { + "text-size": { + "stops": [ + [ + 10, + 14 + ], + [ + 14, + 25 + ] + ] + }, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "symbol-avoid-edges": true, + "text-field": "{_name_global}", + "icon-image": "City large scale" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/town small non capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 17 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 10, + "icon-size": 0.87, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/town small non capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/town large non capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 15 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 10.67, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/town large non capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/small non capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 12 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 10.67, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/small non capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/medium non capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 10.67, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/medium non capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/other capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 18 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 10, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/other capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/town large other capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 14 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 10.67, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/town large other capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/small other capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 11 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 10.67, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/small other capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/medium other capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 10.67, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/medium other capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/town small admin0 capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 16 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 10, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/town small admin0 capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/town large admin0 capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 13 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 10.67, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/town large admin0 capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/small admin0 capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 10 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 10.67, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/small admin0 capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/medium admin0 capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 10.67, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/medium admin0 capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/large other capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 10.67, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/large other capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/x large admin2 capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 12, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/x large admin2 capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/large non capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 10.67, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/large non capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/large admin0 capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 10.67, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/large admin0 capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/x large non capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 12, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/x large non capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/x large admin1 capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 12, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/x large admin1 capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "City small scale/x large admin0 capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-size": 12, + "icon-size": 0.8, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-justify": "left", + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-anchor": "bottom-left", + "text-offset": [ + 0.13, + -0.13 + ], + "text-field": "{_name}", + "icon-image": "City small scale/x large admin0 capital" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 0.5 + } + }, + { + "id": "Continent", + "type": "symbol", + "source": "esri", + "source-layer": "Continent", + "maxzoom": 2, + "layout": { + "text-size": { + "stops": [ + [ + 0, + 9 + ], + [ + 1, + 12 + ] + ] + }, + "text-font": [ + "Ubuntu Bold", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.3, + "icon-allow-overlap": true, + "symbol-avoid-edges": true, + "text-field": "{_name_global}", + "icon-image": "Continent", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2.5 + } + }, + { + "id": "Disputed label point/Island", + "type": "symbol", + "source": "esri", + "source-layer": "Disputed label point", + "filter": [ + "all", + [ + "==", + "_label_class", + 1 + ], + [ + "in", + "DisputeID", + 0 + ] + ], + "minzoom": 6, + "layout": { + "text-size": 10.67, + "text-font": [ + "Ubuntu Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.1, + "text-optional": true, + "icon-allow-overlap": true, + "text-field": "{_name}", + "icon-image": "Disputed label point" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 2, + "text-halo-blur": 2 + } + }, + { + "id": "Disputed label point/Admin0", + "type": "symbol", + "source": "esri", + "source-layer": "Disputed label point", + "filter": [ + "all", + [ + "==", + "_label_class", + 2 + ], + [ + "in", + "DisputeID", + 1021 + ] + ], + "minzoom": 2, + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 2, + 10 + ], + [ + 10, + 15 + ] + ] + }, + "text-font": [ + "Ubuntu Medium", + "Arial Unicode MS Regular" + ], + "text-max-width": 8, + "text-letter-spacing": 0.2, + "text-optional": true, + "icon-allow-overlap": true, + "text-field": "{_name}", + "icon-image": "Disputed label point", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "rgba(0,0,0,0.8)", + "text-halo-width": 1.5, + "text-halo-blur": 0.5 + } + } + ] +} diff --git a/ui-ngx/src/assets/map/openstreetmap_hybrid_reference_style.json b/ui-ngx/src/assets/map/openstreetmap_hybrid_reference_style.json new file mode 100644 index 0000000000..b40e5a3838 --- /dev/null +++ b/ui-ngx/src/assets/map/openstreetmap_hybrid_reference_style.json @@ -0,0 +1,25803 @@ +{ + "version": 8, + "sprite": "https://cdn.arcgis.com/sharing/rest/content/items/f240fe360b434afc87dd989bf0c0b825/resources/sprites/sprite", + "glyphs": "https://basemaps.arcgis.com/arcgis/rest/services/OpenStreetMap_v2/VectorTileServer/resources/fonts/{fontstack}/{range}.pbf", + "sources": { + "esri": { + "type": "vector", + "url": "https://basemaps.arcgis.com/arcgis/rest/services/OpenStreetMap_v2/VectorTileServer", + "attribution": "Map data (c) OpenStreetMap contributors, Microsoft, Facebook, Google, Esri Community Maps contributors, Map layer by Esri", + "tiles": [ + "https://basemaps.arcgis.com/arcgis/rest/services/OpenStreetMap_v2/VectorTileServer/tile/{z}/{y}/{x}.pbf" + ] + } + }, + "layers": [ + { + "id": "country boundary (small scale)/casing", + "type": "line", + "source": "esri", + "source-layer": "country boundary (small scale)", + "minzoom": 2, + "maxzoom": 3, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "stops": [ + [ + 2, + "#000000" + ], + [ + 10, + "#4e4e4e" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 2, + 0.55 + ], + [ + 4, + 0.7 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 2, + 3.33 + ], + [ + 6, + 5 + ] + ] + } + } + }, + { + "id": "country boundary (small scale)/line", + "type": "line", + "source": "esri", + "source-layer": "country boundary (small scale)", + "minzoom": 1, + "maxzoom": 3, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#ffffff", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 1, + 0.55 + ], + [ + 4, + 0.7 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 1, + 1.33 + ], + [ + 6, + 2 + ] + ] + } + } + }, + { + "id": "track (tunnel)/casing", + "type": "line", + "source": "esri", + "source-layer": "track (tunnel)", + "minzoom": 13, + "layout": {}, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 21 + ], + [ + 22, + 36 + ] + ] + } + } + }, + { + "id": "track (tunnel)/line", + "type": "line", + "source": "esri", + "source-layer": "track (tunnel)", + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#fdfdfd", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 18 + ], + [ + 22, + 33 + ] + ] + } + } + }, + { + "id": "service road (tunnel)/minor/casing", + "type": "line", + "source": "esri", + "source-layer": "service road (tunnel)", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 15, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 23 + ], + [ + 22, + 38 + ] + ] + } + } + }, + { + "id": "service road (tunnel)/minor/line", + "type": "line", + "source": "esri", + "source-layer": "service road (tunnel)", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 15, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 20 + ], + [ + 22, + 35 + ] + ] + } + } + }, + { + "id": "service road (tunnel)/normal/casing", + "type": "line", + "source": "esri", + "source-layer": "service road (tunnel)", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 23 + ], + [ + 22, + 38 + ] + ] + } + } + }, + { + "id": "service road (tunnel)/normal/line", + "type": "line", + "source": "esri", + "source-layer": "service road (tunnel)", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 20 + ], + [ + 22, + 35 + ] + ] + } + } + }, + { + "id": "road (tunnel)/other/casing", + "type": "line", + "source": "esri", + "source-layer": "road (tunnel)", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 13, + "layout": {}, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "road (tunnel)/other/line", + "type": "line", + "source": "esri", + "source-layer": "road (tunnel)", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "road (tunnel)/living street/casing", + "type": "line", + "source": "esri", + "source-layer": "road (tunnel)", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 13, + "layout": {}, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "road (tunnel)/living street/line", + "type": "line", + "source": "esri", + "source-layer": "road (tunnel)", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "road (tunnel)/residential, unclassified/casing", + "type": "line", + "source": "esri", + "source-layer": "road (tunnel)", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 13, + "layout": {}, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "road (tunnel)/residential, unclassified/line", + "type": "line", + "source": "esri", + "source-layer": "road (tunnel)", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "road link (tunnel)/tertiary/casing", + "type": "line", + "source": "esri", + "source-layer": "road link (tunnel)", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "road (tunnel)/tertiary/casing", + "type": "line", + "source": "esri", + "source-layer": "road (tunnel)", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 13, + "layout": {}, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "road link (tunnel)/tertiary/line", + "type": "line", + "source": "esri", + "source-layer": "road link (tunnel)", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "road (tunnel)/tertiary/line", + "type": "line", + "source": "esri", + "source-layer": "road (tunnel)", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "road link (tunnel)/secondary/casing", + "type": "line", + "source": "esri", + "source-layer": "road link (tunnel)", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 17 + ], + [ + 22, + 32 + ] + ] + } + } + }, + { + "id": "road (tunnel)/secondary/casing", + "type": "line", + "source": "esri", + "source-layer": "road (tunnel)", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 27 + ], + [ + 22, + 42 + ] + ] + } + } + }, + { + "id": "road link (tunnel)/primary/casing", + "type": "line", + "source": "esri", + "source-layer": "road link (tunnel)", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 19 + ], + [ + 22, + 34 + ] + ] + } + } + }, + { + "id": "road (tunnel)/primary/casing", + "type": "line", + "source": "esri", + "source-layer": "road (tunnel)", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 29 + ], + [ + 22, + 44 + ] + ] + } + } + }, + { + "id": "road link (tunnel)/trunk/casing", + "type": "line", + "source": "esri", + "source-layer": "road link (tunnel)", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 6, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.1 + ], + [ + 13, + 0.12 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 23 + ], + [ + 22, + 38 + ] + ] + } + } + }, + { + "id": "road (tunnel)/trunk/casing", + "type": "line", + "source": "esri", + "source-layer": "road (tunnel)", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 7, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 31 + ], + [ + 22, + 46 + ] + ] + } + } + }, + { + "id": "road link (tunnel)/motorway/casing", + "type": "line", + "source": "esri", + "source-layer": "road link (tunnel)", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 6, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.1 + ], + [ + 13, + 0.12 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 23 + ], + [ + 22, + 38 + ] + ] + } + } + }, + { + "id": "road (tunnel)/motorway/casing", + "type": "line", + "source": "esri", + "source-layer": "road (tunnel)", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 7, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 33 + ], + [ + 22, + 48 + ] + ] + } + } + }, + { + "id": "road link (tunnel)/secondary/line", + "type": "line", + "source": "esri", + "source-layer": "road link (tunnel)", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 14 + ], + [ + 22, + 29 + ] + ] + } + } + }, + { + "id": "road (tunnel)/secondary/line", + "type": "line", + "source": "esri", + "source-layer": "road (tunnel)", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 24 + ], + [ + 22, + 39 + ] + ] + } + } + }, + { + "id": "road link (tunnel)/primary/line", + "type": "line", + "source": "esri", + "source-layer": "road link (tunnel)", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 16 + ], + [ + 22, + 31 + ] + ] + } + } + }, + { + "id": "road (tunnel)/primary/line", + "type": "line", + "source": "esri", + "source-layer": "road (tunnel)", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 26 + ], + [ + 22, + 41 + ] + ] + } + } + }, + { + "id": "road link (tunnel)/trunk/line", + "type": "line", + "source": "esri", + "source-layer": "road link (tunnel)", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 6, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.1 + ], + [ + 13, + 0.12 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 20 + ], + [ + 22, + 35 + ] + ] + } + } + }, + { + "id": "road (tunnel)/trunk/line", + "type": "line", + "source": "esri", + "source-layer": "road (tunnel)", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 7, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 28 + ], + [ + 22, + 42 + ] + ] + } + } + }, + { + "id": "road link (tunnel)/motorway/line", + "type": "line", + "source": "esri", + "source-layer": "road link (tunnel)", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 6, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.1 + ], + [ + 13, + 0.12 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 20 + ], + [ + 22, + 35 + ] + ] + } + } + }, + { + "id": "road (tunnel)/motorway/line", + "type": "line", + "source": "esri", + "source-layer": "road (tunnel)", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 7, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 30 + ], + [ + 22, + 45 + ] + ] + } + } + }, + { + "id": "Daylight road/track/casing", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 13 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 21 + ], + [ + 22, + 36 + ] + ] + } + } + }, + { + "id": "track/casing", + "type": "line", + "source": "esri", + "source-layer": "track", + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 21 + ], + [ + 22, + 36 + ] + ] + } + } + }, + { + "id": "Daylight road/tertiary link/casing", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "road link/tertiary/casing", + "type": "line", + "source": "esri", + "source-layer": "road link", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "Daylight road/secondary link/casing", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 11, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 17 + ], + [ + 22, + 32 + ] + ] + } + } + }, + { + "id": "road link/secondary/casing", + "type": "line", + "source": "esri", + "source-layer": "road link", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 11, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 17 + ], + [ + 22, + 32 + ] + ] + } + } + }, + { + "id": "Daylight road/primary link/casing", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 19 + ], + [ + 22, + 34 + ] + ] + } + } + }, + { + "id": "road link/primary/casing", + "type": "line", + "source": "esri", + "source-layer": "road link", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 19 + ], + [ + 22, + 34 + ] + ] + } + } + }, + { + "id": "Daylight road/trunk link/casing", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 6, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.1 + ], + [ + 13, + 0.12 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 23 + ], + [ + 22, + 38 + ] + ] + } + } + }, + { + "id": "road link/trunk/casing", + "type": "line", + "source": "esri", + "source-layer": "road link", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 6, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.1 + ], + [ + 13, + 0.12 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 23 + ], + [ + 22, + 38 + ] + ] + } + } + }, + { + "id": "Daylight road/motorway link/casing", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 6, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.1 + ], + [ + 13, + 0.12 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 23 + ], + [ + 22, + 38 + ] + ] + } + } + }, + { + "id": "road link/motorway/casing", + "type": "line", + "source": "esri", + "source-layer": "road link", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 6, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.1 + ], + [ + 13, + 0.12 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 23 + ], + [ + 22, + 38 + ] + ] + } + } + }, + { + "id": "Daylight road/service/casing", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 12 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#D4CFC3", + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.5 + ], + [ + 14, + 3.33 + ], + [ + 18, + 23 + ], + [ + 22, + 38 + ] + ] + } + } + }, + { + "id": "service road/minor/casing", + "type": "line", + "source": "esri", + "source-layer": "service road", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 15, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 23 + ], + [ + 22, + 38 + ] + ] + } + } + }, + { + "id": "service road/normal/casing", + "type": "line", + "source": "esri", + "source-layer": "service road", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 23 + ], + [ + 22, + 38 + ] + ] + } + } + }, + { + "id": "road/other/casing", + "type": "line", + "source": "esri", + "source-layer": "road", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "Daylight road/living street/casing", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 11 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "road/living street/casing", + "type": "line", + "source": "esri", + "source-layer": "road", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "Daylight road/residential, unclassified/casing", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 10 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "road/residential, unclassified/casing", + "type": "line", + "source": "esri", + "source-layer": "road", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "Daylight road/tertiary/casing", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "road/tertiary/casing", + "type": "line", + "source": "esri", + "source-layer": "road", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "Daylight road/secondary/casing", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 27 + ], + [ + 22, + 42 + ] + ] + } + } + }, + { + "id": "road/secondary/casing", + "type": "line", + "source": "esri", + "source-layer": "road", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 27 + ], + [ + 22, + 42 + ] + ] + } + } + }, + { + "id": "Daylight road/primary/casing", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 29 + ], + [ + 22, + 44 + ] + ] + } + } + }, + { + "id": "road/primary/casing", + "type": "line", + "source": "esri", + "source-layer": "road", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 29 + ], + [ + 22, + 44 + ] + ] + } + } + }, + { + "id": "Daylight road/trunk/casing", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 6, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 31 + ], + [ + 22, + 46 + ] + ] + } + } + }, + { + "id": "road/trunk/casing", + "type": "line", + "source": "esri", + "source-layer": "road", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 6, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 31 + ], + [ + 22, + 46 + ] + ] + } + } + }, + { + "id": "Daylight road/motorway/casing", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 6, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 33 + ], + [ + 22, + 48 + ] + ] + } + } + }, + { + "id": "road/motorway/casing", + "type": "line", + "source": "esri", + "source-layer": "road", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 6, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 33 + ], + [ + 22, + 48 + ] + ] + } + } + }, + { + "id": "Daylight road/track/line", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 13 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#fdfdfd", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 18 + ], + [ + 22, + 33 + ] + ] + } + } + }, + { + "id": "track/line", + "type": "line", + "source": "esri", + "source-layer": "track", + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#fdfdfd", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 18 + ], + [ + 22, + 33 + ] + ] + } + } + }, + { + "id": "Daylight road/tertiary link/line", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "road link/tertiary/line", + "type": "line", + "source": "esri", + "source-layer": "road link", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "Daylight road/secondary link/line", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 14 + ], + [ + 22, + 29 + ] + ] + } + } + }, + { + "id": "road link/secondary/line", + "type": "line", + "source": "esri", + "source-layer": "road link", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 14 + ], + [ + 22, + 29 + ] + ] + } + } + }, + { + "id": "Daylight road/primary link/line", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 16 + ], + [ + 22, + 31 + ] + ] + } + } + }, + { + "id": "road link/primary/line", + "type": "line", + "source": "esri", + "source-layer": "road link", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 16 + ], + [ + 22, + 31 + ] + ] + } + } + }, + { + "id": "Daylight road/trunk link/line", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 6, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.1 + ], + [ + 13, + 0.12 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 20 + ], + [ + 22, + 35 + ] + ] + } + } + }, + { + "id": "road link/trunk/line", + "type": "line", + "source": "esri", + "source-layer": "road link", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 6, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.1 + ], + [ + 13, + 0.12 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 20 + ], + [ + 22, + 35 + ] + ] + } + } + }, + { + "id": "Daylight road/motorway link/line", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 6, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.1 + ], + [ + 13, + 0.12 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 20 + ], + [ + 22, + 35 + ] + ] + } + } + }, + { + "id": "road link/motorway/line", + "type": "line", + "source": "esri", + "source-layer": "road link", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 6, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.1 + ], + [ + 13, + 0.12 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 20 + ], + [ + 22, + 35 + ] + ] + } + } + }, + { + "id": "Daylight road/service/line", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 12 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 20 + ], + [ + 22, + 35 + ] + ] + } + } + }, + { + "id": "service road/minor/line", + "type": "line", + "source": "esri", + "source-layer": "service road", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 15, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 20 + ], + [ + 22, + 35 + ] + ] + } + } + }, + { + "id": "service road/normal/line", + "type": "line", + "source": "esri", + "source-layer": "service road", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 20 + ], + [ + 22, + 35 + ] + ] + } + } + }, + { + "id": "road/other/line", + "type": "line", + "source": "esri", + "source-layer": "road", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "Daylight road/living street/line", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 11 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "road/living street/line", + "type": "line", + "source": "esri", + "source-layer": "road", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "Daylight road/residential, unclassified/line", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 10 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "road/residential, unclassified/line", + "type": "line", + "source": "esri", + "source-layer": "road", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "Daylight road/tertiary/line", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "road/tertiary/line", + "type": "line", + "source": "esri", + "source-layer": "road", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "Daylight road/secondary/line", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 24 + ], + [ + 22, + 39 + ] + ] + } + } + }, + { + "id": "road/secondary/line", + "type": "line", + "source": "esri", + "source-layer": "road", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 24 + ], + [ + 22, + 39 + ] + ] + } + } + }, + { + "id": "Daylight road/primary/line", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 26 + ], + [ + 22, + 41 + ] + ] + } + } + }, + { + "id": "road/primary/line", + "type": "line", + "source": "esri", + "source-layer": "road", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 26 + ], + [ + 22, + 41 + ] + ] + } + } + }, + { + "id": "Daylight road/trunk/line", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 6, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 28 + ], + [ + 22, + 42 + ] + ] + } + } + }, + { + "id": "road/trunk/line", + "type": "line", + "source": "esri", + "source-layer": "road", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 6, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 28 + ], + [ + 22, + 42 + ] + ] + } + } + }, + { + "id": "Daylight road/motorway/line", + "type": "line", + "source": "esri", + "source-layer": "Daylight road", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 6, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 30 + ], + [ + 22, + 45 + ] + ] + } + } + }, + { + "id": "road/motorway/line", + "type": "line", + "source": "esri", + "source-layer": "road", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 6, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 30 + ], + [ + 22, + 45 + ] + ] + } + } + }, + { + "id": "track (bridge)/casing", + "type": "line", + "source": "esri", + "source-layer": "track (bridge)", + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 21 + ], + [ + 22, + 36 + ] + ] + } + } + }, + { + "id": "service road (bridge)/minor/casing", + "type": "line", + "source": "esri", + "source-layer": "service road (bridge)", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 15, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 23 + ], + [ + 22, + 38 + ] + ] + } + } + }, + { + "id": "service road (bridge)/normal/casing", + "type": "line", + "source": "esri", + "source-layer": "service road (bridge)", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 23 + ], + [ + 22, + 38 + ] + ] + } + } + }, + { + "id": "road (bridge)/other/casing", + "type": "line", + "source": "esri", + "source-layer": "road (bridge)", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "road (bridge)/living street/casing", + "type": "line", + "source": "esri", + "source-layer": "road (bridge)", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "road (bridge)/residential, unclassified/casing", + "type": "line", + "source": "esri", + "source-layer": "road (bridge)", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "road link (bridge)/tertiary/casing", + "type": "line", + "source": "esri", + "source-layer": "road link (bridge)", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "road (bridge)/tertiary/casing", + "type": "line", + "source": "esri", + "source-layer": "road (bridge)", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "road link (bridge)/secondary/casing", + "type": "line", + "source": "esri", + "source-layer": "road link (bridge)", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 12, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 17 + ], + [ + 22, + 32 + ] + ] + } + } + }, + { + "id": "road (bridge)/secondary/casing", + "type": "line", + "source": "esri", + "source-layer": "road (bridge)", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 27 + ], + [ + 22, + 42 + ] + ] + } + } + }, + { + "id": "road link (bridge)/primary/casing", + "type": "line", + "source": "esri", + "source-layer": "road link (bridge)", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 19 + ], + [ + 22, + 34 + ] + ] + } + } + }, + { + "id": "road (bridge)/primary/casing", + "type": "line", + "source": "esri", + "source-layer": "road (bridge)", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 29 + ], + [ + 22, + 44 + ] + ] + } + } + }, + { + "id": "road link (bridge)/trunk/casing", + "type": "line", + "source": "esri", + "source-layer": "road link (bridge)", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 6, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.1 + ], + [ + 13, + 0.12 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 23 + ], + [ + 22, + 38 + ] + ] + } + } + }, + { + "id": "road (bridge)/trunk/casing", + "type": "line", + "source": "esri", + "source-layer": "road (bridge)", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 7, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 31 + ], + [ + 22, + 46 + ] + ] + } + } + }, + { + "id": "road link (bridge)/motorway/casing", + "type": "line", + "source": "esri", + "source-layer": "road link (bridge)", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 6, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.1 + ], + [ + 13, + 0.12 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 23 + ], + [ + 22, + 38 + ] + ] + } + } + }, + { + "id": "road (bridge)/motorway/casing", + "type": "line", + "source": "esri", + "source-layer": "road (bridge)", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 7, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 33 + ], + [ + 22, + 48 + ] + ] + } + } + }, + { + "id": "track (bridge)/line", + "type": "line", + "source": "esri", + "source-layer": "track (bridge)", + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fdfdfd", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 18 + ], + [ + 22, + 33 + ] + ] + } + } + }, + { + "id": "service road (bridge)/minor/line", + "type": "line", + "source": "esri", + "source-layer": "service road (bridge)", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 15, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 20 + ], + [ + 22, + 35 + ] + ] + } + } + }, + { + "id": "service road (bridge)/normal/line", + "type": "line", + "source": "esri", + "source-layer": "service road (bridge)", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 20 + ], + [ + 22, + 35 + ] + ] + } + } + }, + { + "id": "road (bridge)/other/line", + "type": "line", + "source": "esri", + "source-layer": "road (bridge)", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "road (bridge)/living street/line", + "type": "line", + "source": "esri", + "source-layer": "road (bridge)", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "road (bridge)/residential, unclassified/line", + "type": "line", + "source": "esri", + "source-layer": "road (bridge)", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "road link (bridge)/tertiary/line", + "type": "line", + "source": "esri", + "source-layer": "road link (bridge)", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "road (bridge)/tertiary/line", + "type": "line", + "source": "esri", + "source-layer": "road (bridge)", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "road link (bridge)/secondary/line", + "type": "line", + "source": "esri", + "source-layer": "road link (bridge)", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 14 + ], + [ + 22, + 29 + ] + ] + } + } + }, + { + "id": "road link (bridge)/primary/line", + "type": "line", + "source": "esri", + "source-layer": "road link (bridge)", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 16 + ], + [ + 22, + 31 + ] + ] + } + } + }, + { + "id": "road link (bridge)/trunk/line", + "type": "line", + "source": "esri", + "source-layer": "road link (bridge)", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 6, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.1 + ], + [ + 13, + 0.12 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 20 + ], + [ + 22, + 35 + ] + ] + } + } + }, + { + "id": "road link (bridge)/motorway/line", + "type": "line", + "source": "esri", + "source-layer": "road link (bridge)", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 6, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.1 + ], + [ + 13, + 0.12 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 20 + ], + [ + 22, + 35 + ] + ] + } + } + }, + { + "id": "road (bridge)/secondary/line", + "type": "line", + "source": "esri", + "source-layer": "road (bridge)", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 24 + ], + [ + 22, + 39 + ] + ] + } + } + }, + { + "id": "road (bridge)/primary/line", + "type": "line", + "source": "esri", + "source-layer": "road (bridge)", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 26 + ], + [ + 22, + 41 + ] + ] + } + } + }, + { + "id": "road (bridge)/trunk/line", + "type": "line", + "source": "esri", + "source-layer": "road (bridge)", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 7, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 28 + ], + [ + 22, + 42 + ] + ] + } + } + }, + { + "id": "road (bridge)/motorway/line", + "type": "line", + "source": "esri", + "source-layer": "road (bridge)", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 7, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 30 + ], + [ + 22, + 45 + ] + ] + } + } + }, + { + "id": "administrative boundary/level 6", + "type": "line", + "source": "esri", + "source-layer": "administrative boundary", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#ffffff", + "line-opacity": 0.55, + "line-dasharray": [ + 2, + 2 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.5 + ], + [ + 13, + 2 + ], + [ + 18, + 2.75 + ] + ] + } + } + }, + { + "id": "administrative boundary/level 5", + "type": "line", + "source": "esri", + "source-layer": "administrative boundary", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#ffffff", + "line-opacity": 0.55, + "line-dasharray": [ + 2, + 2 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.5 + ], + [ + 13, + 2 + ], + [ + 18, + 2.75 + ] + ] + } + } + }, + { + "id": "administrative boundary/level 4", + "type": "line", + "source": "esri", + "source-layer": "administrative boundary", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 3, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#ffffff", + "line-dasharray": [ + 4, + 3 + ], + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 3, + 0.55 + ], + [ + 4, + 0.7 + ], + [ + 10, + 0.55 + ] + ] + }, + "line-width": { + "base": 1.0, + "stops": [ + [ + 3, + 1.33 + ], + [ + 6, + 1.75 + ], + [ + 18, + 4 + ] + ] + } + } + }, + { + "id": "administrative boundary/level 3", + "type": "line", + "source": "esri", + "source-layer": "administrative boundary", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 3, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#ffffff", + "line-dasharray": [ + 4, + 3 + ], + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 3, + 0.55 + ], + [ + 4, + 0.7 + ], + [ + 10, + 0.55 + ] + ] + }, + "line-width": { + "base": 1.0, + "stops": [ + [ + 3, + 1.33 + ], + [ + 6, + 1.75 + ], + [ + 18, + 4 + ] + ] + } + } + }, + { + "id": "administrative boundary/level 2/casing", + "type": "line", + "source": "esri", + "source-layer": "administrative boundary", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 3, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "stops": [ + [ + 2, + "#000000" + ], + [ + 10, + "#4e4e4e" + ], + [ + 11, + "#cccccc" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 2, + 0.55 + ], + [ + 4, + 0.7 + ], + [ + 10, + 0.55 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 2, + 3.33 + ], + [ + 6, + 5 + ], + [ + 18, + 12 + ] + ] + } + } + }, + { + "id": "administrative boundary/level 2/line", + "type": "line", + "source": "esri", + "source-layer": "administrative boundary", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 3, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#ffffff", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 1, + 0.55 + ], + [ + 4, + 0.7 + ], + [ + 10, + 0.55 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 1, + 1.33 + ], + [ + 6, + 2 + ], + [ + 18, + 4 + ] + ] + } + } + }, + { + "id": "station area/aerialway station, halt", + "type": "symbol", + "source": "esri", + "source-layer": "station area", + "filter": [ + "all", + [ + "==", + "_symbol", + 1 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 12, + "layout": { + "icon-image": "station area/aerialway station, halt", + "icon-size": { + "stops": [ + [ + 12, + 0.67 + ], + [ + 14, + 1 + ] + ] + }, + "icon-allow-overlap": true + } + }, + { + "id": "station area/tram stop", + "type": "symbol", + "source": "esri", + "source-layer": "station area", + "filter": [ + "all", + [ + "==", + "_symbol", + 0 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 13, + "layout": { + "icon-image": "station area/tram stop", + "icon-size": { + "stops": [ + [ + 13, + 0.67 + ], + [ + 14, + 1 + ] + ] + }, + "icon-allow-overlap": true + } + }, + { + "id": "Daylight address area/label/Number/Unit", + "type": "symbol", + "source": "esri", + "source-layer": "Daylight address area/label", + "minzoom": 16, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 19, + 12 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 16, + 8 + ], + [ + 19, + 9 + ] + ] + }, + "text-padding": { + "stops": [ + [ + 16, + 3 + ], + [ + 19, + 3.3 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 16, + 1 + ], + [ + 19, + 1.1 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "OSM address area/label/Number/Name", + "type": "symbol", + "source": "esri", + "source-layer": "OSM address area/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 16, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 19, + 12 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 16, + 8 + ], + [ + 19, + 9 + ] + ] + }, + "text-padding": { + "stops": [ + [ + 16, + 3 + ], + [ + 19, + 3.3 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 16, + 1 + ], + [ + 19, + 1.1 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "OSM address area/label/Number/Unit/Name", + "type": "symbol", + "source": "esri", + "source-layer": "OSM address area/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 17, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 19, + 12 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 16, + 8 + ], + [ + 19, + 9 + ] + ] + }, + "text-padding": { + "stops": [ + [ + 16, + 3 + ], + [ + 19, + 3.3 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 16, + 1 + ], + [ + 19, + 1.1 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "OSM address area/label/Number/Flat/Name", + "type": "symbol", + "source": "esri", + "source-layer": "OSM address area/label", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 17, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 19, + 11 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 16, + 8 + ], + [ + 19, + 9 + ] + ] + }, + "text-padding": { + "stops": [ + [ + 16, + 3 + ], + [ + 19, + 3.3 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 16, + 1 + ], + [ + 19, + 1.1 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "OSM address point/Number/Name", + "type": "symbol", + "source": "esri", + "source-layer": "OSM address point", + "minzoom": 16, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 19, + 12 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 16, + 8 + ], + [ + 19, + 9 + ] + ] + }, + "text-padding": { + "stops": [ + [ + 16, + 3 + ], + [ + 19, + 3.3 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 16, + 1 + ], + [ + 19, + 1.1 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "OSM address point/Number/Unit/Name", + "type": "symbol", + "source": "esri", + "source-layer": "OSM address point", + "minzoom": 17, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 19, + 12 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 16, + 8 + ], + [ + 19, + 9 + ] + ] + }, + "text-padding": { + "stops": [ + [ + 16, + 3 + ], + [ + 19, + 3.3 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 16, + 1 + ], + [ + 19, + 1.1 + ] + ] + }, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "OSM address point/Number/Flat/Name", + "type": "symbol", + "source": "esri", + "source-layer": "OSM address point", + "minzoom": 17, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 19, + 11 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 16, + 8 + ], + [ + 19, + 9 + ] + ] + }, + "text-padding": { + "stops": [ + [ + 16, + 3 + ], + [ + 19, + 3.3 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 16, + 1 + ], + [ + 19, + 1.1 + ] + ] + }, + "text-field": "{_name2}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Daylight building/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Daylight building/label", + "minzoom": 15, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 18, + 12 + ] + ] + }, + "text-letter-spacing": 0.08, + "text-max-width": { + "stops": [ + [ + 15, + 5.5 + ], + [ + 16, + 6 + ] + ] + }, + "text-field": "{_name}", + "text-padding": 3, + "text-line-height": 1, + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "OSM major building/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "OSM major building/label", + "minzoom": 14, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 14, + 10 + ], + [ + 18, + 12 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 14, + 5 + ], + [ + 16, + 6 + ] + ] + }, + "text-field": "{_name}", + "text-padding": 3, + "text-line-height": 1, + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "OSM building/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "OSM building/label", + "minzoom": 15, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 18, + 12 + ] + ] + }, + "text-letter-spacing": 0.08, + "text-max-width": { + "stops": [ + [ + 15, + 5.5 + ], + [ + 16, + 6 + ] + ] + }, + "text-field": "{_name}", + "text-padding": 3, + "text-line-height": 1, + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity area/parking street side", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 108 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity area/parking street side", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/parking", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 104 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity area/parking", + "icon-size": { + "stops": [ + [ + 15, + 0.7 + ], + [ + 17, + 1 + ] + ] + }, + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/motorcycle parking", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 103 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity area/motorcycle parking", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/bicycle parking", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 102 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity area/bicycle parking", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/obelisk", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 98 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity area/obelisk", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/vehicle inspection", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 95 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity area/vehicle inspection", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/bicycle repair station", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 94 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity area/bicycle repair station", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/office", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 91 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity area/office", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/casino", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 86 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity area/casino", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/boat rental", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 84 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity area/boat rental", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/atm", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 76 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity area/atm", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/bank", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 75 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity area/bank", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/bar", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 74 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity area/bar", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/bicycle rental", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 72 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity area/bicycle rental", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/bus stop", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 71 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity area/bus stop", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/bus station", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 70 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity area/bus station", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/taxi", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 69 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity area/taxi", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/traffic signal", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 68 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity area/traffic signal", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/cafe", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 67 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity area/cafe", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/camp site", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 66 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity area/camp site", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/caravan site", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 65 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity area/caravan site", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/car rental", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 64 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity area/car rental", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/car wash", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 63 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity area/car wash", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/cinema", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 61 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity area/cinema", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/nightclub", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 59 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity area/nightclub", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/fire station", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 58 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity area/fire station", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/charging station", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 56 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity area/charging station", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/fuel", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 55 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity area/fuel", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/hospital", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 52 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 14, + "layout": { + "icon-image": "amenity area/hospital", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/hostel", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 51 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity area/hostel", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/hotel", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 50 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity area/hotel", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/motel", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 49 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity area/motel", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/ice cream", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 48 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity area/ice cream", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/library", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 46 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity area/library", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/courthouse", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 45 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity area/courthouse", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/community centre", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 44 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity area/community centre", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/townhall", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 41 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity area/townhall", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/museum", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 40 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity area/museum", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/pharmacy", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 39 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity area/pharmacy", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/clinic", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 38 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity area/clinic", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/dentist", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 36 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity area/dentist", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/police", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 34 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity area/police", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/post office", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 32 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity area/post office", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/pub", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 31 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity area/pub", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/food court", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 29 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity area/food court", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/restaurant", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 28 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity area/restaurant", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/fast food", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 27 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity area/fast food", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/theatre", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 25 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity area/theatre", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/arts centre", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 24 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity area/arts centre", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/prison", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 22 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity area/prison", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/viewpoint", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 21 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity area/viewpoint", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/monument", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 19 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity area/monument", + "icon-size": { + "stops": [ + [ + 15, + 0.7 + ], + [ + 17, + 1 + ] + ] + }, + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/marketplace", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 16 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity area/marketplace", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/fitness", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 14 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity area/fitness", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/golf course", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 12 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 14, + "layout": { + "icon-image": "amenity area/golf course", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/helipad", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 6 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity area/helipad", + "icon-allow-overlap": true + } + }, + { + "id": "amenity area/ferry terminal", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area", + "filter": [ + "all", + [ + "==", + "_symbol", + 5 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 14, + "layout": { + "icon-image": "amenity area/ferry terminal", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/fabric", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 70 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/fabric", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/massage", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 66 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/massage", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/video games", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 58 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/video games", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/variety store", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 57 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/variety store", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/tyres", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 56 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/tyres", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/supermarket", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 51 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 15, + "layout": { + "icon-image": "shop area/supermarket", + "icon-size": { + "stops": [ + [ + 15, + 0.7 + ], + [ + 17, + 1 + ] + ] + }, + "icon-allow-overlap": true + } + }, + { + "id": "shop area/sports", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 49 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/sports", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/shoes", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 48 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/shoes", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/pet", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 45 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/pet", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/outdoor", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 43 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/outdoor", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/optician", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 41 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/optician", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/motorcycle", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 37 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/motorcycle", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/mobile phone", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 36 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/mobile phone", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/laundry", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 33 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/laundry", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/jewelry", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 32 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/jewelry", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/hardware", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 30 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/hardware", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/hairdresser", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 29 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/hairdresser", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/gift", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 27 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/gift", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/furniture", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 25 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/furniture", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/florist", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 24 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/florist", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/clothes", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 23 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/clothes", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/electronics", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 22 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/electronics", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/department store", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 21 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 15, + "layout": { + "icon-image": "shop area/department store", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/convenience", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 17 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/convenience", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/computer", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 16 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/computer", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/car repair", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 12 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/car repair", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/car parts", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 11 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/car parts", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/car", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 10 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/car", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/books", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 8 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/books", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/bicycle", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 7 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/bicycle", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/beverages", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 6 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/beverages", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/beauty", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 4 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/beauty", + "icon-allow-overlap": true + } + }, + { + "id": "shop area/bakery", + "type": "symbol", + "source": "esri", + "source-layer": "shop area", + "filter": [ + "all", + [ + "==", + "_symbol", + 3 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "shop area/bakery", + "icon-allow-overlap": true + } + }, + { + "id": "private access area/charging station", + "type": "symbol", + "source": "esri", + "source-layer": "private access area", + "filter": [ + "all", + [ + "==", + "_symbol", + 5 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "private access area/charging station", + "icon-allow-overlap": true + } + }, + { + "id": "private access area/parking street side", + "type": "symbol", + "source": "esri", + "source-layer": "private access area", + "filter": [ + "all", + [ + "==", + "_symbol", + 17 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "private access area/parking street side", + "icon-allow-overlap": true + } + }, + { + "id": "private access area/parking", + "type": "symbol", + "source": "esri", + "source-layer": "private access area", + "filter": [ + "all", + [ + "==", + "_symbol", + 4 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "private access area/parking", + "icon-allow-overlap": true + } + }, + { + "id": "private access area/motorcycle parking", + "type": "symbol", + "source": "esri", + "source-layer": "private access area", + "filter": [ + "all", + [ + "==", + "_symbol", + 3 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 17, + "layout": { + "icon-image": "private access area/motorcycle parking", + "icon-allow-overlap": true + } + }, + { + "id": "private access area/bicycle parking", + "type": "symbol", + "source": "esri", + "source-layer": "private access area", + "filter": [ + "all", + [ + "==", + "_symbol", + 2 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 17, + "layout": { + "icon-image": "private access area/bicycle parking", + "icon-allow-overlap": true + } + }, + { + "id": "private access area/bicycle repair station", + "type": "symbol", + "source": "esri", + "source-layer": "private access area", + "filter": [ + "all", + [ + "==", + "_symbol", + 1 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "private access area/bicycle repair station", + "icon-allow-overlap": true + } + }, + { + "id": "aerodrome area", + "type": "symbol", + "source": "esri", + "source-layer": "aerodrome area", + "filter": [ + "==", + "$type", + "Point" + ], + "minzoom": 9, + "maxzoom": 17, + "layout": { + "icon-image": "aerodrome area", + "icon-size": { + "stops": [ + [ + 9, + 0.7 + ], + [ + 12, + 1 + ] + ] + }, + "icon-allow-overlap": true + } + }, + { + "id": "place of worship area/other", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship area", + "filter": [ + "all", + [ + "==", + "_symbol", + 9 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship area/other", + "icon-allow-overlap": true + } + }, + { + "id": "place of worship area/Taoist", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship area", + "filter": [ + "all", + [ + "==", + "_symbol", + 8 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship area/Taoist", + "icon-allow-overlap": true + } + }, + { + "id": "place of worship area/Sikh", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship area", + "filter": [ + "all", + [ + "==", + "_symbol", + 7 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship area/Sikh", + "icon-allow-overlap": true + } + }, + { + "id": "place of worship area/Shinto", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship area", + "filter": [ + "all", + [ + "==", + "_symbol", + 6 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship area/Shinto", + "icon-allow-overlap": true + } + }, + { + "id": "place of worship area/Muslim", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship area", + "filter": [ + "all", + [ + "==", + "_symbol", + 5 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship area/Muslim", + "icon-allow-overlap": true + } + }, + { + "id": "place of worship area/Jewish", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship area", + "filter": [ + "all", + [ + "==", + "_symbol", + 4 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship area/Jewish", + "icon-allow-overlap": true + } + }, + { + "id": "place of worship area/Hindu", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship area", + "filter": [ + "all", + [ + "==", + "_symbol", + 3 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship area/Hindu", + "icon-allow-overlap": true + } + }, + { + "id": "place of worship area/Christian - other", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship area", + "filter": [ + "all", + [ + "==", + "_symbol", + 2 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship area/Christian - other", + "icon-allow-overlap": true + } + }, + { + "id": "place of worship area/Christian - Jehovah's Witness", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship area", + "filter": [ + "all", + [ + "==", + "_symbol", + 1 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship area/Christian - Jehovah's Witness", + "icon-allow-overlap": true + } + }, + { + "id": "place of worship area/Buddhist", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship area", + "filter": [ + "all", + [ + "==", + "_symbol", + 0 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship area/Buddhist", + "icon-allow-overlap": true + } + }, + { + "id": "vending machine area/public transport tickets", + "type": "symbol", + "source": "esri", + "source-layer": "vending machine area", + "filter": [ + "all", + [ + "==", + "_symbol", + 2 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "vending machine area/public transport tickets", + "icon-allow-overlap": true + } + }, + { + "id": "vending machine area/parking tickets", + "type": "symbol", + "source": "esri", + "source-layer": "vending machine area", + "filter": [ + "all", + [ + "==", + "_symbol", + 1 + ], + [ + "==", + "$type", + "Point" + ] + ], + "minzoom": 18, + "layout": { + "icon-image": "vending machine area/parking tickets", + "icon-allow-overlap": true + } + }, + { + "id": "amenity line/slipway", + "type": "symbol", + "source": "esri", + "source-layer": "amenity line", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 16, + "layout": { + "symbol-placement": "line", + "icon-image": "amenity line/slipway", + "icon-rotation-alignment": "map", + "icon-allow-overlap": false + } + }, + { + "id": "amenity line/ford", + "type": "symbol", + "source": "esri", + "source-layer": "amenity line", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 15, + "layout": { + "symbol-placement": "line", + "icon-image": "amenity line/ford", + "icon-rotation-alignment": "map", + "icon-allow-overlap": false + } + }, + { + "id": "vending machine point/public transport tickets", + "type": "symbol", + "source": "esri", + "source-layer": "vending machine point", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 18, + "layout": { + "icon-image": "vending machine point/public transport tickets", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "vending machine point/parking tickets", + "type": "symbol", + "source": "esri", + "source-layer": "vending machine point", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 18, + "layout": { + "icon-image": "vending machine point/parking tickets", + "icon-allow-overlap": false + } + }, + { + "id": "junction point/label/exit name and number", + "type": "symbol", + "source": "esri", + "source-layer": "junction point", + "minzoom": 15, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 18, + 12 + ] + ] + }, + "text-letter-spacing": 0.05, + "text-field": "{_name1}", + "icon-image": "junction point/exit name and number/{_len1}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport", + "text-optional": true + }, + "paint": { + "text-color": "#ffa878", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "track (tunnel)/label/alternate name", + "type": "symbol", + "source": "esri", + "source-layer": "track (tunnel)/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 15, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-size": { + "stops": [ + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "track (tunnel)/label/name", + "type": "symbol", + "source": "esri", + "source-layer": "track (tunnel)/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 15, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-size": { + "stops": [ + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "service road (tunnel)/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "service road (tunnel)/label", + "minzoom": 15, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road (tunnel)/label/living street", + "type": "symbol", + "source": "esri", + "source-layer": "road (tunnel)/label", + "filter": [ + "==", + "_label_class", + 12 + ], + "minzoom": 14, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 14, + 8 + ], + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road (tunnel)/label/residential, unclassified shield text", + "type": "symbol", + "source": "esri", + "source-layer": "road (tunnel)/label", + "filter": [ + "==", + "_label_class", + 10 + ], + "minzoom": 14, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 14, + 8 + ], + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road (tunnel)/label/residential, unclassified name", + "type": "symbol", + "source": "esri", + "source-layer": "road (tunnel)/label", + "filter": [ + "==", + "_label_class", + 11 + ], + "minzoom": 14, + "layout": { + "symbol-placement": "line", + "symbol-spacing": { + "stops": [ + [ + 14, + 300 + ], + [ + 16, + 400 + ] + ] + }, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 14, + 8 + ], + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road (tunnel)/label/tertiary name", + "type": "symbol", + "source": "esri", + "source-layer": "road (tunnel)/label", + "filter": [ + "==", + "_label_class", + 9 + ], + "minzoom": 13, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 13, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road (tunnel)/label/secondary name", + "type": "symbol", + "source": "esri", + "source-layer": "road (tunnel)/label", + "filter": [ + "==", + "_label_class", + 8 + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 12, + 8 + ], + [ + 13, + 9 + ], + [ + 14, + 10 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road (tunnel)/label/primary name", + "type": "symbol", + "source": "esri", + "source-layer": "road (tunnel)/label", + "filter": [ + "==", + "_label_class", + 7 + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 12, + 8 + ], + [ + 13, + 9 + ], + [ + 14, + 10 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road (tunnel)/label/trunk name", + "type": "symbol", + "source": "esri", + "source-layer": "road (tunnel)/label", + "filter": [ + "==", + "_label_class", + 6 + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 12, + 8 + ], + [ + 13, + 9 + ], + [ + 14, + 10 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road (tunnel)/label/motorway name", + "type": "symbol", + "source": "esri", + "source-layer": "road (tunnel)/label", + "filter": [ + "==", + "_label_class", + 5 + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 12, + 8 + ], + [ + 13, + 9 + ], + [ + 14, + 10 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road (tunnel)/label/tertiary shield", + "type": "symbol", + "source": "esri", + "source-layer": "road (tunnel)/label", + "filter": [ + "==", + "_label_class", + 4 + ], + "minzoom": 13, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 11, + 8 + ], + [ + 15, + 10 + ], + [ + 17, + 12 + ] + ] + }, + "icon-size": { + "stops": [ + [ + 11, + 1 + ], + [ + 15, + 1.2 + ], + [ + 17, + 1.6 + ] + ] + }, + "text-field": "{_name}", + "icon-image": "road (tunnel)/tertiary shield/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport" + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "road (tunnel)/label/secondary shield", + "type": "symbol", + "source": "esri", + "source-layer": "road (tunnel)/label", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 11, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 11, + 8 + ], + [ + 15, + 10 + ], + [ + 17, + 12 + ] + ] + }, + "icon-size": { + "stops": [ + [ + 11, + 1 + ], + [ + 15, + 1.2 + ], + [ + 17, + 1.6 + ] + ] + }, + "text-field": "{_name}", + "icon-image": "road (tunnel)/secondary shield/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport" + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "road (tunnel)/label/primary shield", + "type": "symbol", + "source": "esri", + "source-layer": "road (tunnel)/label", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 10, + 8 + ], + [ + 15, + 10 + ], + [ + 17, + 12 + ] + ] + }, + "icon-size": { + "stops": [ + [ + 10, + 1 + ], + [ + 15, + 1.2 + ], + [ + 17, + 1.6 + ] + ] + }, + "text-field": "{_name}", + "icon-image": "road (tunnel)/primary shield/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport" + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "road (tunnel)/label/trunk shield", + "type": "symbol", + "source": "esri", + "source-layer": "road (tunnel)/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 10, + 8 + ], + [ + 15, + 10 + ], + [ + 17, + 12 + ] + ] + }, + "icon-size": { + "stops": [ + [ + 10, + 1 + ], + [ + 15, + 1.2 + ], + [ + 17, + 1.6 + ] + ] + }, + "text-field": "{_name}", + "icon-image": "road (tunnel)/trunk shield/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport" + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "road (tunnel)/label/motorway shield", + "type": "symbol", + "source": "esri", + "source-layer": "road (tunnel)/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 9, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 9, + 8 + ], + [ + 15, + 10 + ], + [ + 17, + 12 + ] + ] + }, + "icon-size": { + "stops": [ + [ + 9, + 1 + ], + [ + 15, + 1.2 + ], + [ + 17, + 1.6 + ] + ] + }, + "text-field": "{_name}", + "icon-image": "road (tunnel)/motorway shield/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport" + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "track/label/alternate name", + "type": "symbol", + "source": "esri", + "source-layer": "track/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 15, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-max-angle": 30, + "text-size": { + "stops": [ + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "track/label/name", + "type": "symbol", + "source": "esri", + "source-layer": "track/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 15, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-max-angle": 30, + "text-size": { + "stops": [ + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "service road/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "service road/label", + "minzoom": 15, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-max-angle": 30, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road/label/living street", + "type": "symbol", + "source": "esri", + "source-layer": "road/label", + "filter": [ + "==", + "_label_class", + 12 + ], + "minzoom": 14, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-max-angle": 30, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 14, + 8 + ], + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road/label/residential, unclassified shield text", + "type": "symbol", + "source": "esri", + "source-layer": "road/label", + "filter": [ + "==", + "_label_class", + 10 + ], + "minzoom": 14, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 14, + 8 + ], + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road/label/residential, unclassified name", + "type": "symbol", + "source": "esri", + "source-layer": "road/label", + "filter": [ + "==", + "_label_class", + 11 + ], + "minzoom": 14, + "layout": { + "symbol-placement": "line", + "symbol-spacing": { + "stops": [ + [ + 14, + 300 + ], + [ + 16, + 400 + ] + ] + }, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-max-angle": 30, + "text-size": { + "stops": [ + [ + 14, + 8 + ], + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road/label/tertiary name", + "type": "symbol", + "source": "esri", + "source-layer": "road/label", + "filter": [ + "==", + "_label_class", + 9 + ], + "minzoom": 13, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-max-angle": 30, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 13, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road/label/secondary name", + "type": "symbol", + "source": "esri", + "source-layer": "road/label", + "filter": [ + "==", + "_label_class", + 8 + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 12, + 8 + ], + [ + 13, + 9 + ], + [ + 14, + 10 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road/label/primary name", + "type": "symbol", + "source": "esri", + "source-layer": "road/label", + "filter": [ + "==", + "_label_class", + 7 + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 12, + 8 + ], + [ + 13, + 9 + ], + [ + 14, + 10 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road/label/trunk name", + "type": "symbol", + "source": "esri", + "source-layer": "road/label", + "filter": [ + "==", + "_label_class", + 6 + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 12, + 8 + ], + [ + 13, + 9 + ], + [ + 14, + 10 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road/label/motorway name", + "type": "symbol", + "source": "esri", + "source-layer": "road/label", + "filter": [ + "==", + "_label_class", + 5 + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 12, + 8 + ], + [ + 13, + 9 + ], + [ + 14, + 10 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road/label/tertiary shield", + "type": "symbol", + "source": "esri", + "source-layer": "road/label", + "filter": [ + "==", + "_label_class", + 4 + ], + "minzoom": 13, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 11, + 8 + ], + [ + 15, + 10 + ], + [ + 17, + 12 + ] + ] + }, + "icon-size": { + "stops": [ + [ + 11, + 1 + ], + [ + 15, + 1.2 + ], + [ + 17, + 1.6 + ] + ] + }, + "text-field": "{_name}", + "icon-image": "road/tertiary shield/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport" + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "road/label/secondary shield", + "type": "symbol", + "source": "esri", + "source-layer": "road/label", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 11, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 11, + 8 + ], + [ + 15, + 10 + ], + [ + 17, + 12 + ] + ] + }, + "icon-size": { + "stops": [ + [ + 11, + 1 + ], + [ + 15, + 1.2 + ], + [ + 17, + 1.6 + ] + ] + }, + "text-field": "{_name}", + "icon-image": "road/secondary shield/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport" + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "road/label/primary shield", + "type": "symbol", + "source": "esri", + "source-layer": "road/label", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 10, + 8 + ], + [ + 15, + 10 + ], + [ + 17, + 12 + ] + ] + }, + "icon-size": { + "stops": [ + [ + 10, + 1 + ], + [ + 15, + 1.2 + ], + [ + 17, + 1.6 + ] + ] + }, + "text-field": "{_name}", + "icon-image": "road/primary shield/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport" + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "road/label/trunk shield", + "type": "symbol", + "source": "esri", + "source-layer": "road/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 10, + 8 + ], + [ + 15, + 10 + ], + [ + 17, + 12 + ] + ] + }, + "icon-size": { + "stops": [ + [ + 10, + 1 + ], + [ + 15, + 1.2 + ], + [ + 17, + 1.6 + ] + ] + }, + "text-field": "{_name}", + "icon-image": "road/trunk shield/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport" + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "road/label/motorway shield", + "type": "symbol", + "source": "esri", + "source-layer": "road/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 9, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-font": [ + "Arial Unicode MS Regular" + ], + "icon-size": { + "stops": [ + [ + 9, + 1 + ], + [ + 15, + 1.2 + ], + [ + 17, + 1.6 + ] + ] + }, + "text-size": { + "stops": [ + [ + 9, + 8 + ], + [ + 15, + 10 + ], + [ + 17, + 12 + ] + ] + }, + "text-field": "{_name}", + "icon-image": "road/motorway shield/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport" + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "track (bridge)/label/alternate name", + "type": "symbol", + "source": "esri", + "source-layer": "track (bridge)/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 15, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-size": { + "stops": [ + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "track (bridge)/label/name", + "type": "symbol", + "source": "esri", + "source-layer": "track (bridge)/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 15, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-size": { + "stops": [ + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "service road (bridge)/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "service road (bridge)/label", + "minzoom": 15, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road (bridge)/label/living street", + "type": "symbol", + "source": "esri", + "source-layer": "road (bridge)/label", + "filter": [ + "==", + "_label_class", + 12 + ], + "minzoom": 14, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 14, + 8 + ], + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road (bridge)/label/residential, unclassified shield text", + "type": "symbol", + "source": "esri", + "source-layer": "road (bridge)/label", + "filter": [ + "==", + "_label_class", + 10 + ], + "minzoom": 14, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 14, + 8 + ], + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road (bridge)/label/residential, unclassified name", + "type": "symbol", + "source": "esri", + "source-layer": "road (bridge)/label", + "filter": [ + "==", + "_label_class", + 11 + ], + "minzoom": 14, + "layout": { + "symbol-placement": "line", + "symbol-spacing": { + "stops": [ + [ + 14, + 300 + ], + [ + 16, + 400 + ] + ] + }, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 14, + 8 + ], + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road (bridge)/label/tertiary name", + "type": "symbol", + "source": "esri", + "source-layer": "road (bridge)/label", + "filter": [ + "==", + "_label_class", + 9 + ], + "minzoom": 13, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 13, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road (bridge)/label/secondary name", + "type": "symbol", + "source": "esri", + "source-layer": "road (bridge)/label", + "filter": [ + "==", + "_label_class", + 8 + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 12, + 8 + ], + [ + 13, + 9 + ], + [ + 14, + 10 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road (bridge)/label/primary name", + "type": "symbol", + "source": "esri", + "source-layer": "road (bridge)/label", + "filter": [ + "==", + "_label_class", + 7 + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 12, + 8 + ], + [ + 13, + 9 + ], + [ + 14, + 10 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road (bridge)/label/trunk name", + "type": "symbol", + "source": "esri", + "source-layer": "road (bridge)/label", + "filter": [ + "==", + "_label_class", + 6 + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 12, + 8 + ], + [ + 13, + 9 + ], + [ + 14, + 10 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road (bridge)/label/motorway name", + "type": "symbol", + "source": "esri", + "source-layer": "road (bridge)/label", + "filter": [ + "==", + "_label_class", + 5 + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 12, + 8 + ], + [ + 13, + 9 + ], + [ + 14, + 10 + ], + [ + 16, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "road (bridge)/label/tertiary shield", + "type": "symbol", + "source": "esri", + "source-layer": "road (bridge)/label", + "filter": [ + "==", + "_label_class", + 4 + ], + "minzoom": 13, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 11, + 8 + ], + [ + 15, + 10 + ], + [ + 17, + 12 + ] + ] + }, + "icon-size": { + "stops": [ + [ + 11, + 1 + ], + [ + 15, + 1.2 + ], + [ + 17, + 1.6 + ] + ] + }, + "text-field": "{_name}", + "icon-image": "road (bridge)/tertiary shield/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport" + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "road (bridge)/label/secondary shield", + "type": "symbol", + "source": "esri", + "source-layer": "road (bridge)/label", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 11, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 11, + 8 + ], + [ + 15, + 10 + ], + [ + 17, + 12 + ] + ] + }, + "icon-size": { + "stops": [ + [ + 11, + 1 + ], + [ + 15, + 1.2 + ], + [ + 17, + 1.6 + ] + ] + }, + "text-field": "{_name}", + "icon-image": "road (bridge)/secondary shield/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport" + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "road (bridge)/label/primary shield", + "type": "symbol", + "source": "esri", + "source-layer": "road (bridge)/label", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 12, + 18 + ], + [ + 15, + 10 + ], + [ + 17, + 12 + ] + ] + }, + "icon-size": { + "stops": [ + [ + 12, + 1 + ], + [ + 15, + 1.2 + ], + [ + 17, + 1.6 + ] + ] + }, + "text-field": "{_name}", + "icon-image": "road (bridge)/primary shield/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport" + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "road (bridge)/label/trunk shield", + "type": "symbol", + "source": "esri", + "source-layer": "road (bridge)/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 11, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 11, + 8 + ], + [ + 15, + 10 + ], + [ + 17, + 12 + ] + ] + }, + "icon-size": { + "stops": [ + [ + 11, + 1 + ], + [ + 15, + 1.2 + ], + [ + 17, + 1.6 + ] + ] + }, + "text-field": "{_name}", + "icon-image": "road (bridge)/trunk shield/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport" + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "road (bridge)/label/motorway shield", + "type": "symbol", + "source": "esri", + "source-layer": "road (bridge)/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 11, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 760, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 11, + 8 + ], + [ + 15, + 10 + ], + [ + 17, + 12 + ] + ] + }, + "icon-size": { + "stops": [ + [ + 11, + 1 + ], + [ + 15, + 1.2 + ], + [ + 17, + 1.6 + ] + ] + }, + "text-field": "{_name}", + "icon-image": "road (bridge)/motorway shield/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport" + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "station area/label/aerialway station", + "type": "symbol", + "source": "esri", + "source-layer": "station area/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 13, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "station area/label/tram stop", + "type": "symbol", + "source": "esri", + "source-layer": "station area/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 15, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "waterway/label/dock", + "type": "symbol", + "source": "esri", + "source-layer": "waterway/label", + "minzoom": 11, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 8, + 10 + ], + [ + 12, + 11 + ], + [ + 16, + 12 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 8, + 7 + ], + [ + 12, + 8 + ], + [ + 16, + 9 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 8, + 1.13 + ], + [ + 12, + 1.16 + ], + [ + 16, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "water area/label/water, basin, reservoir", + "type": "symbol", + "source": "esri", + "source-layer": "water area/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 7, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 7, + 8 + ], + [ + 8, + 10 + ], + [ + 12, + 11 + ], + [ + 16, + 12 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 7, + 6 + ], + [ + 8, + 7 + ], + [ + 12, + 8 + ], + [ + 16, + 9 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 7, + 1 + ], + [ + 8, + 1.13 + ], + [ + 12, + 1.16 + ], + [ + 16, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "landcover/label/forest", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 31 + ], + "minzoom": 9, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 7, + 10 + ], + [ + 12, + 11 + ], + [ + 15, + 12 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 7, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 7, + 1 + ], + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#def2b6", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/park", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 29 + ], + "minzoom": 11, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 9, + 10 + ], + [ + 12, + 11 + ], + [ + 15, + 12 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 9, + 1 + ], + [ + 12, + 1.1 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#def2b6", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/sand", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 26 + ], + "minzoom": 13, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 12, + 11 + ], + [ + 15, + 12 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 7, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#d9d1ba", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/vineyard, orchard", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 23 + ], + "minzoom": 11, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 9, + 10 + ], + [ + 12, + 11 + ], + [ + 15, + 12 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 9, + 1 + ], + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#def2b6", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/farmland", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 22 + ], + "minzoom": 11, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 9, + 10 + ], + [ + 12, + 11 + ], + [ + 15, + 12 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 9, + 1 + ], + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#def2b6", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/farmyard", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 21 + ], + "minzoom": 11, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 9, + 10 + ], + [ + 12, + 11 + ], + [ + 15, + 12 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 9, + 1 + ], + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#def2b6", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/industrial", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 19 + ], + "minzoom": 13, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 12, + 11 + ], + [ + 15, + 12 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/garages", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 16 + ], + "minzoom": 15, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 14, + 9 + ], + [ + 16, + 10 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 14, + 1.15 + ], + [ + 16, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/allotments", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 15 + ], + "minzoom": 11, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 9, + 10 + ], + [ + 12, + 11 + ], + [ + 15, + 12 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 9, + 1 + ], + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#def2b6", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/education", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 12 + ], + "minzoom": 10, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 9, + 10 + ], + [ + 15, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 9, + 1 + ], + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#e8cc99", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/commercial", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 14 + ], + "minzoom": 10, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 9, + 10 + ], + [ + 12, + 11 + ], + [ + 15, + 12 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 9, + 1 + ], + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#e8cc99", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/retail", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 13 + ], + "minzoom": 11, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 9, + 10 + ], + [ + 12, + 11 + ], + [ + 15, + 12 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 9, + 1 + ], + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#e8cc99", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/religious", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 32 + ], + "minzoom": 13, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 12, + 11 + ], + [ + 15, + 12 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 12, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#e8cc99", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "cemetery/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "cemetery/label", + "minzoom": 12, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 9, + 10 + ], + [ + 13, + 11 + ], + [ + 16, + 12 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 9, + 1 + ], + [ + 13, + 1.15 + ], + [ + 16, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#def2b6", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/danger area", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 11 + ], + "minzoom": 8, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 8, + 10 + ], + [ + 12, + 11 + ], + [ + 15, + 12 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 8, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 8, + 1 + ], + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/highway services", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 10 + ], + "minzoom": 11, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 9, + 10 + ], + [ + 12, + 11 + ], + [ + 15, + 12 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 9, + 1 + ], + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/power station", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 9 + ], + "minzoom": 11, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 9, + 10 + ], + [ + 12, + 11 + ], + [ + 15, + 12 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 9, + 1 + ], + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/power substation", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 8 + ], + "minzoom": 12, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 14, + 11 + ], + [ + 16, + 12 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 12, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 12, + 1 + ], + [ + 14, + 1.15 + ], + [ + 16, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/apron", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 7 + ], + "minzoom": 9, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 9, + 10 + ], + [ + 12, + 11 + ], + [ + 15, + 12 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 9, + 1 + ], + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/camping", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 6 + ], + "minzoom": 13, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 12, + 11 + ], + [ + 15, + 12 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#def2b6", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/sports", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 5 + ], + "minzoom": 14, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 9, + 10 + ], + [ + 12, + 11 + ], + [ + 15, + 12 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 9, + 1 + ], + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#e8cc99", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/pitch, track", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 4 + ], + "minzoom": 14, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 9, + 10 + ], + [ + 12, + 11 + ], + [ + 15, + 12 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 9, + 1 + ], + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#e8cc99", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/dog park, fitness station", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 13, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 12, + 11 + ], + [ + 15, + 12 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 9, + 1 + ], + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#def2b6", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/swimming pool, marina", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 14, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 13, + 10 + ], + [ + 15, + 11 + ], + [ + 17, + 12 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 13, + 5 + ], + [ + 15, + 7 + ], + [ + 17, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 13, + 1 + ], + [ + 15, + 1.15 + ], + [ + 17, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "landcover/label/military", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 7, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 7, + 10 + ], + [ + 10, + 11 + ], + [ + 13, + 12 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 7, + 5 + ], + [ + 11, + 7 + ], + [ + 14, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 7, + 1 + ], + [ + 10, + 1.15 + ], + [ + 13, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/tourist attraction", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 33 + ], + "minzoom": 16, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 12, + "text-max-width": 6, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#e8cc99", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "landcover/label/amusement park", + "type": "symbol", + "source": "esri", + "source-layer": "landcover/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 13, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 13, + 10 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#e8cc99", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "beach/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "beach/label", + "minzoom": 13, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 12, + 11 + ], + [ + 15, + 12 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 9, + 5 + ], + [ + 14, + 7 + ], + [ + 16, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 9, + 1 + ], + [ + 12, + 1.15 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#d9d1ba", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "transportation area/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "transportation area/label", + "minzoom": 16, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "aboriginal land/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "aboriginal land/label", + "minzoom": 7, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 7, + 10 + ], + [ + 18, + 14 + ] + ] + }, + "text-letter-spacing": 0.05, + "text-field": "{_name}", + "text-max-width": 6, + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "protected area/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "protected area/label", + "minzoom": 7, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 7, + 10 + ], + [ + 18, + 14 + ] + ] + }, + "text-field": "{_name}", + "text-letter-spacing": 0.05, + "text-max-width": 7, + "text-optional": true + }, + "paint": { + "text-color": "#def2b6", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "island/label/islet", + "type": "symbol", + "source": "esri", + "source-layer": "island/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 11, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 11, + 9 + ], + [ + 12, + 10 + ], + [ + 14, + 12 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 11, + 7 + ], + [ + 14, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 10, + 1 + ], + [ + 14, + 1.2 + ] + ] + }, + "text-letter-spacing": 0.05, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#d9d1ba", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "island/label/island", + "type": "symbol", + "source": "esri", + "source-layer": "island/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 11, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 10, + 11 + ], + [ + 13, + 12 + ], + [ + 18, + 15 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 11, + 7 + ], + [ + 14, + 8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 10, + 1.15 + ], + [ + 13, + 1.2 + ] + ] + }, + "text-letter-spacing": 0.05, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#d9d1ba", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "amenity area/label/aeroway gate", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area/label", + "filter": [ + "==", + "_label_class", + 31 + ], + "minzoom": 16, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1, + "text-field": "{_name}", + "text-letter-spacing": 0.05, + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity area/label/parking", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area/label", + "filter": [ + "==", + "_label_class", + 30 + ], + "minzoom": 18, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 11, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity area/label/places to eat/drink", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area/label", + "filter": [ + "==", + "_label_class", + 28 + ], + "minzoom": 18, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 11, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#7ceae5", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity area/label/all other office", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area/label", + "filter": [ + "==", + "_label_class", + 27 + ], + "minzoom": 18, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 11, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity area/label/medium office", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area/label", + "filter": [ + "==", + "_label_class", + 26 + ], + "minzoom": 18, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 11, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity area/label/transportation", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area/label", + "filter": [ + "==", + "_label_class", + 19 + ], + "minzoom": 16, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity area/label/marketplace", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area/label", + "filter": [ + "==", + "_label_class", + 18 + ], + "minzoom": 16, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity area/label/viewpoint", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area/label", + "filter": [ + "==", + "_label_class", + 17 + ], + "minzoom": 15, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity area/label/golf course", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area/label", + "filter": [ + "==", + "_label_class", + 12 + ], + "minzoom": 14, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 14, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#84d435", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity area/label/atm", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area/label", + "filter": [ + "==", + "_label_class", + 9 + ], + "minzoom": 18, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 11, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity area/label/hospital", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area/label", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 15, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#FF9393", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity area/label/medical", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area/label", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 17, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#FF9393", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity area/label/helipad", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 15, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "bottom", + "text-offset": [ + 0, + -1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity area/label/ferry terminal", + "type": "symbol", + "source": "esri", + "source-layer": "amenity area/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 14, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 14, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "shop area/label/massage", + "type": "symbol", + "source": "esri", + "source-layer": "shop area/label", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 18, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 11, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop area/label/supermarket, department store", + "type": "symbol", + "source": "esri", + "source-layer": "shop area/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 15, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "private access area/label/charging station", + "type": "symbol", + "source": "esri", + "source-layer": "private access area/label", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 18, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "private access area/label/bicycle repair station", + "type": "symbol", + "source": "esri", + "source-layer": "private access area/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 18, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "private access area/label/parking", + "type": "symbol", + "source": "esri", + "source-layer": "private access area/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 17, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "aerodrome area/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "aerodrome area/label", + "minzoom": 10, + "maxzoom": 17, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 9, + 10 + ], + [ + 12, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.05, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "place of worship area/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship area/label", + "minzoom": 16, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "vending machine area/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "vending machine area/label", + "minzoom": 18, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 11, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "station area/label/halt", + "type": "symbol", + "source": "esri", + "source-layer": "station area/label", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 14, + "layout": { + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 14, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 5, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.05, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "aeroway/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "aeroway/label", + "minzoom": 14, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 750, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 14, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "water line (intermittent)/label/ditch, drain", + "type": "symbol", + "source": "esri", + "source-layer": "water line (intermittent)/label", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 14, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 1000, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-field": "{_name}", + "text-offset": [ + 0, + -0.5 + ], + "text-max-angle": 30, + "text-optional": true + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "water line (intermittent)/label/stream", + "type": "symbol", + "source": "esri", + "source-layer": "water line (intermittent)/label", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 14, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 1000, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-field": "{_name}", + "text-offset": [ + 0, + -0.5 + ], + "text-max-angle": 30, + "text-optional": true + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "water line (intermittent)/label/canal", + "type": "symbol", + "source": "esri", + "source-layer": "water line (intermittent)/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 1000, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-field": "{_name}", + "text-offset": [ + 0, + -0.5 + ], + "text-max-angle": 30, + "text-optional": true + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "water line (intermittent)/label/river", + "type": "symbol", + "source": "esri", + "source-layer": "water line (intermittent)/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 1000, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-field": "{_name}", + "text-offset": [ + 0, + -0.5 + ], + "text-max-angle": 30, + "text-optional": true + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "water line (perennial)/label/ditch, drain", + "type": "symbol", + "source": "esri", + "source-layer": "water line (perennial)/label", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 14, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 1000, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-field": "{_name}", + "text-offset": [ + 0, + -0.5 + ], + "text-max-angle": 30, + "text-optional": true + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "water line (perennial)/label/stream", + "type": "symbol", + "source": "esri", + "source-layer": "water line (perennial)/label", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 14, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 1000, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-field": "{_name}", + "text-offset": [ + 0, + -0.5 + ], + "text-max-angle": 30, + "text-optional": true + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "water line (perennial)/label/canal", + "type": "symbol", + "source": "esri", + "source-layer": "water line (perennial)/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 1000, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-field": "{_name}", + "text-offset": [ + 0, + -0.5 + ], + "text-max-angle": 30, + "text-optional": true + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "water line (perennial)/label/river", + "type": "symbol", + "source": "esri", + "source-layer": "water line (perennial)/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 1000, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-field": "{_name}", + "text-offset": [ + 0, + -0.5 + ], + "text-max-angle": 30, + "text-optional": true + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "station point/subway entrance", + "type": "symbol", + "source": "esri", + "source-layer": "station point", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 17, + "layout": { + "icon-image": "station point/subway entrance", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name3}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 17.5, + 0 + ], + [ + 17.6, + 1 + ] + ] + }, + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "station point/aerialway station", + "type": "symbol", + "source": "esri", + "source-layer": "station point", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 12, + "layout": { + "icon-image": "station point/aerialway station, halt", + "icon-size": { + "stops": [ + [ + 12, + 0.67 + ], + [ + 14, + 1 + ] + ] + }, + "icon-allow-overlap": false, + "text-max-width": 6, + "text-line-height": 1.1, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 13, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 12.5, + 0 + ], + [ + 12.6, + 1 + ] + ] + }, + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "station point/tram stop", + "type": "symbol", + "source": "esri", + "source-layer": "station point", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 13, + "layout": { + "icon-image": "station point/tram stop", + "icon-size": { + "stops": [ + [ + 13, + 0.67 + ], + [ + 14, + 1 + ] + ] + }, + "icon-allow-overlap": false, + "text-max-width": 6, + "text-line-height": 1.1, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 14.5, + 0 + ], + [ + 14.6, + 1 + ] + ] + }, + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "station point/halt", + "type": "symbol", + "source": "esri", + "source-layer": "station point", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 12, + "layout": { + "icon-image": "station point/aerialway station, halt", + "icon-size": { + "stops": [ + [ + 12, + 0.67 + ], + [ + 14, + 1 + ] + ] + }, + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 14, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name2}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 13.5, + 0 + ], + [ + 13.6, + 1 + ] + ] + }, + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/education", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "minzoom": 16, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-max-width": 7, + "text-field": "{_name40}", + "text-optional": true + }, + "paint": { + "text-color": "#e8cc99", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/attraction", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "minzoom": 16, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-max-width": 7, + "text-field": "{_name39}", + "text-optional": true + }, + "paint": { + "text-color": "#e8cc99", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/aeroway gate", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "minzoom": 16, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1, + "text-field": "{_name37}", + "text-letter-spacing": 0.05, + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "parking entrance multi-storey", + "type": "symbol", + "source": "esri", + "source-layer": "parking entrance multi-storey", + "minzoom": 18, + "layout": { + "icon-image": "parking entrance multi-storey", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 11, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/parking entrance underground", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 117 + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity point/parking entrance underground", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 11, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name36}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/parking street side", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 121 + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity point/parking street side", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 0.8 + ], + "text-field": "{_name35}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/parking", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 116 + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity point/parking", + "icon-allow-overlap": false, + "icon-size": { + "stops": [ + [ + 15, + 0.7 + ], + [ + 17, + 1 + ] + ] + }, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name35}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/motorcycle parking", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 115 + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity point/motorcycle parking", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name35}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/bicycle parking", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 114 + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity point/bicycle parking", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name35}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/obelisk", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 110 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/obelisk", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name25}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/vehicle inspection", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 107 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/vehicle inspection", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name25}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/bicycle repair station", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 106 + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity point/bicycle repair station", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 11, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name16}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/all other office", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 103 + ], + "minzoom": 18, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 11, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1 + ], + "text-field": "{_name32}", + "text-allow-overlap": true, + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/medium office", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 103 + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity point/office", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1 + ], + "text-field": "{_name31}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/casino", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 97 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/casino", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name25}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/boat rental", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 95 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/boat rental", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name24}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/bay, strait", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "minzoom": 13, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 12, + "text-max-width": 6, + "text-line-height": 1.1, + "text-field": "{_name10}", + "text-letter-spacing": 0.1, + "text-optional": true + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "amenity point/atm", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 86 + ], + "minzoom": 18, + "layout": { + "icon-image": "amenity point/atm", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 11, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name9}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/bank", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 85 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/bank", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name25}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/bar", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 84 + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity point/bar", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name33}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 16.5, + 0 + ], + [ + 16.6, + 1 + ] + ] + }, + "text-color": "#7ceae5", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/bicycle rental", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 82 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/bicycle rental", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name24}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/bus stop", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 81 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/bus stop", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name24}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/bus station", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 80 + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity point/bus station", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name24}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 15.5, + 0 + ], + [ + 15.6, + 1 + ] + ] + }, + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/taxi", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 79 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/taxi", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name24}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/traffic signal", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 78 + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity point/traffic signal", + "icon-allow-overlap": false + } + }, + { + "id": "amenity point/toll booth", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 77 + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity point/toll booth", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name24}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 15.5, + 0 + ], + [ + 15.6, + 1 + ] + ] + }, + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/cafe", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 76 + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity point/cafe", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name33}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 16.5, + 0 + ], + [ + 16.6, + 1 + ] + ] + }, + "text-color": "#7ceae5", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/camp site", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 75 + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity point/camp site", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name26}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 15.5, + 0 + ], + [ + 15.6, + 1 + ] + ] + }, + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/ford", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 74 + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity point/ford", + "icon-allow-overlap": false + } + }, + { + "id": "amenity point/caravan site", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 73 + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity point/caravan site", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name26}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 15.5, + 0 + ], + [ + 15.6, + 1 + ] + ] + }, + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/car rental", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 72 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/car rental", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name24}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/car wash", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 71 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/car wash", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name25}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/cinema", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 69 + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity point/cinema", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name25}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 15.5, + 0 + ], + [ + 15.6, + 1 + ] + ] + }, + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/nightclub", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 67 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/nightclub", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name25}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/fire station", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 66 + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity point/fire station", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name25}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 15.5, + 0 + ], + [ + 15.6, + 1 + ] + ] + }, + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/charging station", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 64 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/charging station", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name24}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/fuel", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 63 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/fuel", + "icon-allow-overlap": false, + "icon-size": { + "stops": [ + [ + 16, + 0.8 + ], + [ + 17, + 1 + ] + ] + }, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name24}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/hospital", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 60 + ], + "minzoom": 14, + "layout": { + "icon-image": "amenity point/hospital", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name3}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 14.5, + 0 + ], + [ + 14.6, + 1 + ] + ] + }, + "text-color": "#FF9393", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/hostel", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 59 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/hostel", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name26}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/hotel", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 58 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/hotel", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name26}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/motel", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 57 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/motel", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name26}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/ice cream", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 56 + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity point/ice cream", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name33}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 16.5, + 0 + ], + [ + 16.6, + 1 + ] + ] + }, + "text-color": "#7ceae5", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/library", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 54 + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity point/library", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name25}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 15.5, + 0 + ], + [ + 15.6, + 1 + ] + ] + }, + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/courthouse", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 53 + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity point/courthouse", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name25}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 15.5, + 0 + ], + [ + 15.6, + 1 + ] + ] + }, + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/community centre", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 52 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/community centre", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name25}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/townhall", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 49 + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity point/townhall", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name25}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 15.5, + 0 + ], + [ + 15.6, + 1 + ] + ] + }, + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/museum", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 48 + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity point/museum", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name25}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 15.5, + 0 + ], + [ + 15.6, + 1 + ] + ] + }, + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/pharmacy", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 47 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/pharmacy", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name2}", + "text-optional": true + }, + "paint": { + "text-color": "#FF9393", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/clinic", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 46 + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity point/clinic", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name2}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 15.5, + 0 + ], + [ + 15.6, + 1 + ] + ] + }, + "text-color": "#FF9393", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/dentist", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 44 + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity point/dentist", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name2}", + "text-optional": true + }, + "paint": { + "text-color": "#FF9393", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/police", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 40 + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity point/police", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name25}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 15.5, + 0 + ], + [ + 15.6, + 1 + ] + ] + }, + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/post office", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 38 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/post office", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name25}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/pub", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 37 + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity point/pub", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name33}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 16.5, + 0 + ], + [ + 16.6, + 1 + ] + ] + }, + "text-color": "#7ceae5", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/food court", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 35 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/food court", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name33}", + "text-optional": true + }, + "paint": { + "text-color": "#7ceae5", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/restaurant", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 34 + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity point/restaurant", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name33}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 16.5, + 0 + ], + [ + 16.6, + 1 + ] + ] + }, + "text-color": "#7ceae5", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/fast food", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 33 + ], + "minzoom": 17, + "layout": { + "icon-image": "amenity point/fast food", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name33}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 16.5, + 0 + ], + [ + 16.6, + 1 + ] + ] + }, + "text-color": "#7ceae5", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/theatre", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 31 + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity point/theatre", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name25}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 15.5, + 0 + ], + [ + 15.6, + 1 + ] + ] + }, + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/arts centre", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 30 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/arts centre", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name25}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/prison", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 28 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/prison", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name25}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/viewpoint", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 27 + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity point/viewpoint", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name19}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/monument", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 25 + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity point/monument", + "icon-allow-overlap": false, + "icon-size": { + "stops": [ + [ + 15, + 0.7 + ], + [ + 17, + 1 + ] + ] + }, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name15}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/marketplace", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 22 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/marketplace", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name23}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/fitness", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 20 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/fitness", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name12}", + "text-optional": true + }, + "paint": { + "text-color": "#84d435", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/dog park", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 19 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/dog park", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name12}", + "text-optional": true + }, + "paint": { + "text-color": "#84d435", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/golf course", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 17 + ], + "minzoom": 14, + "layout": { + "icon-image": "amenity point/golf course", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 14, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name13}", + "text-optional": true + }, + "paint": { + "text-color": "#84d435", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "amenity point/slipway", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 11 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/slipway", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name24}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/helipad", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 10 + ], + "minzoom": 15, + "layout": { + "icon-image": "amenity point/helipad", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "bottom", + "text-offset": [ + 0, + -1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "amenity point/ferry terminal", + "type": "symbol", + "source": "esri", + "source-layer": "amenity point", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 14, + "layout": { + "icon-image": "amenity point/ferry terminal", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 14, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "shop point/fabric", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 70 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/fabric", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/massage", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 66 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/massage", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name2}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/video games", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 58 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/video games", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/variety store", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 57 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/variety store", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/tyres", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 56 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/tyres", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/supermarket", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 51 + ], + "minzoom": 15, + "layout": { + "icon-image": "shop point/supermarket", + "icon-allow-overlap": false, + "icon-size": { + "stops": [ + [ + 15, + 0.7 + ], + [ + 17, + 1 + ] + ] + }, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/sports", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 49 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/sports", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/shoes", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 48 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/shoes", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/pet", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 45 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/pet", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/outdoor", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 44 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/outdoor", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/other", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 43 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/other", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/optician", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 42 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/optician", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/motorcycle", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 39 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/motorcycle", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/mobile phone", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 38 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/mobile phone", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/mall", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 36 + ], + "minzoom": 16, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "center", + "text-field": "{_name1}", + "text-letter-spacing": 0.08, + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/laundry", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 35 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/laundry", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/jewelry", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 33 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/jewelry", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/hardware", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 31 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/hardware", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/hairdresser", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 30 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/hairdresser", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/gift", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 29 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/gift", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/furniture", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 27 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/furniture", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/florist", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 26 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/florist", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/electronics", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 24 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/electronics", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/department store", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 23 + ], + "minzoom": 15, + "layout": { + "icon-image": "shop point/department store", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/convenience", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 18 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/convenience", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/computer", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 16 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/computer", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/clothes", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 14 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/clothes", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/car repair", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 12 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/car repair", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/car parts", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 11 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/car parts", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/car", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 10 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/car", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/books", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/books", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/bicycle", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/bicycle", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/beverages", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/beverages", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/beauty", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/beauty", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "shop point/bakery", + "type": "symbol", + "source": "esri", + "source-layer": "shop point", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 17, + "layout": { + "icon-image": "shop point/bakery", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 17, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffc420", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "private access point/charging station", + "type": "symbol", + "source": "esri", + "source-layer": "private access point", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 16, + "layout": { + "icon-image": "amenity point/charging station", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name2}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "private access point/parking street side", + "type": "symbol", + "source": "esri", + "source-layer": "private access point", + "filter": [ + "==", + "_symbol", + 19 + ], + "minzoom": 18, + "layout": { + "icon-image": "private access point/parking street side", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 0.8 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "private access point/parking", + "type": "symbol", + "source": "esri", + "source-layer": "private access point", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 16, + "layout": { + "icon-image": "private access point/parking", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "private access point/motorcycle parking", + "type": "symbol", + "source": "esri", + "source-layer": "private access point", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 17, + "layout": { + "icon-image": "private access point/motorcycle parking", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "private access point/bicycle parking", + "type": "symbol", + "source": "esri", + "source-layer": "private access point", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 17, + "layout": { + "icon-image": "private access point/bicycle parking", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "private access point/bicycle repair station", + "type": "symbol", + "source": "esri", + "source-layer": "private access point", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 18, + "layout": { + "icon-image": "private access point/bicycle repair station", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.08, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "aerodrome point", + "type": "symbol", + "source": "esri", + "source-layer": "aerodrome point", + "minzoom": 9, + "maxzoom": 17, + "layout": { + "icon-image": "aerodrome point", + "icon-allow-overlap": false, + "icon-size": { + "stops": [ + [ + 9, + 0.7 + ], + [ + 12, + 1 + ] + ] + }, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 9, + 10 + ], + [ + 12, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.05, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 9.5, + 0 + ], + [ + 9.6, + 1 + ] + ] + }, + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "place of worship point/other", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship point", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship point/other", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "place of worship point/Taoist", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship point", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship point/Taoist", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "place of worship point/Sikh", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship point", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship point/Sikh", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "place of worship point/Shinto", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship point", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship point/Shinto", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "place of worship point/Muslim", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship point", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship point/Muslim", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "place of worship point/Jewish", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship point", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship point/Jewish", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "place of worship point/Hindu", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship point", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship point/Hindu", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "place of worship point/Christian - other", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship point", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship point/Christian - other", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "place of worship point/Christian - Jehovah's Witness", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship point", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship point/Christian - Jehovah's Witness", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "place of worship point/Buddhist", + "type": "symbol", + "source": "esri", + "source-layer": "place of worship point", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 16, + "layout": { + "icon-image": "place of worship point/Buddhist", + "icon-allow-overlap": false, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 16, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 6, + "text-line-height": 1.1, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#dbaef2", + "text-halo-color": "#000000", + "text-halo-width": 1.1 + } + }, + { + "id": "junction area/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "junction area/label", + "minzoom": 17, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-max-width": 6, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "junction point/traffic signal", + "type": "symbol", + "source": "esri", + "source-layer": "junction point", + "minzoom": 17, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 10, + "text-max-width": 6, + "text-anchor": "top", + "text-offset": [ + 0, + -0.9 + ], + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "railway station area", + "type": "symbol", + "source": "esri", + "source-layer": "railway station area", + "filter": [ + "==", + "$type", + "Point" + ], + "minzoom": 13, + "layout": { + "icon-image": "railway station area", + "icon-size": { + "stops": [ + [ + 13, + 0.44 + ], + [ + 14, + 0.67 + ], + [ + 15, + 1 + ] + ] + }, + "icon-allow-overlap": true + } + }, + { + "id": "railway station area/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "railway station area/label", + "minzoom": 13, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 13, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 5, + "text-line-height": { + "stops": [ + [ + 13, + 1 + ], + [ + 14, + 1.1 + ] + ] + }, + "text-field": "{_name}", + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.05, + "text-optional": false + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "railway station point", + "type": "symbol", + "source": "esri", + "source-layer": "railway station point", + "minzoom": 13, + "layout": { + "icon-image": "railway station point", + "icon-size": { + "stops": [ + [ + 13, + 0.44 + ], + [ + 14, + 0.67 + ], + [ + 15, + 1 + ] + ] + }, + "icon-allow-overlap": true, + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 13, + 10 + ], + [ + 18, + 11 + ] + ] + }, + "text-max-width": 5, + "text-line-height": { + "stops": [ + [ + 13, + 1 + ], + [ + 14, + 1.1 + ] + ] + }, + "text-anchor": "top", + "text-offset": [ + 0, + 1.3 + ], + "text-letter-spacing": 0.05, + "text-field": "{_name}", + "text-optional": false + }, + "paint": { + "text-opacity": { + "stops": [ + [ + 13.5, + 0 + ], + [ + 13.6, + 1 + ] + ] + }, + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "place (small)/locality", + "type": "symbol", + "source": "esri", + "source-layer": "place (small)", + "minzoom": 15, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 16, + 12 + ] + ] + }, + "text-max-width": 7, + "text-padding": { + "stops": [ + [ + 15, + 7 + ], + [ + 16, + 8.4 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 15, + 1.1 + ], + [ + 16, + 1.2 + ] + ] + }, + "text-letter-spacing": 0.05, + "text-field": "{_name6}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 2 + } + }, + { + "id": "place (small)/neighborhood", + "type": "symbol", + "source": "esri", + "source-layer": "place (small)", + "minzoom": 14, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 14, + 10 + ], + [ + 15, + 12 + ] + ] + }, + "text-max-width": 7, + "text-padding": { + "stops": [ + [ + 14, + 7 + ], + [ + 15, + 8.4 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 14, + 1.1 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-letter-spacing": 0.05, + "text-field": "{_name5}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 2 + } + }, + { + "id": "place (small)/quarter", + "type": "symbol", + "source": "esri", + "source-layer": "place (small)", + "minzoom": 13, + "maxzoom": 16, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 13, + 11 + ], + [ + 14, + 12 + ], + [ + 15, + 14 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 13, + 5 + ], + [ + 14, + 8 + ], + [ + 15, + 15 + ] + ] + }, + "text-padding": { + "stops": [ + [ + 13, + 7.7 + ], + [ + 14, + 8.4 + ], + [ + 15, + 9.8 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 13, + 1 + ], + [ + 14, + 1.1 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-letter-spacing": 0.05, + "text-field": "{_name4}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 2 + } + }, + { + "id": "place (small)/square", + "type": "symbol", + "source": "esri", + "source-layer": "place (small)", + "minzoom": 16, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": 11, + "text-max-width": 6, + "text-line-height": 1.1, + "text-field": "{_name3}", + "text-letter-spacing": 0.05, + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 2 + } + }, + { + "id": "place (small)/hamlet", + "type": "symbol", + "source": "esri", + "source-layer": "place (small)", + "minzoom": 13, + "maxzoom": 17, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 14, + 10 + ], + [ + 15, + 12 + ] + ] + }, + "text-max-width": 7, + "text-padding": { + "stops": [ + [ + 14, + 7 + ], + [ + 15, + 8.4 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 14, + 1.1 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-letter-spacing": 0.05, + "text-field": "{_name2}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 2 + } + }, + { + "id": "place (small)/village", + "type": "symbol", + "source": "esri", + "source-layer": "place (small)", + "minzoom": 11, + "maxzoom": 16, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 11, + 10 + ], + [ + 12, + 11 + ], + [ + 13, + 13 + ], + [ + 14, + 14 + ], + [ + 15, + 15 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 11, + 11 + ], + [ + 12, + 12 + ], + [ + 13, + 13 + ], + [ + 14, + 14 + ], + [ + 15, + 15 + ] + ] + }, + "text-padding": { + "stops": [ + [ + 11, + 7 + ], + [ + 12, + 7.7 + ], + [ + 13, + 9.1 + ], + [ + 14, + 9.8 + ], + [ + 15, + 10.5 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 11, + 1 + ], + [ + 12, + 1.12 + ], + [ + 13, + 1.15 + ], + [ + 14, + 1.17 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-letter-spacing": 0.05, + "text-field": "{_name1}", + "text-optional": true + }, + "paint": { + "text-color": { + "stops": [ + [ + 11, + "#ffffff" + ], + [ + 14, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 2 + } + }, + { + "id": "place (small)/suburb", + "type": "symbol", + "source": "esri", + "source-layer": "place (small)", + "minzoom": 11, + "maxzoom": 16, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 11, + 11 + ], + [ + 12, + 12 + ], + [ + 13, + 14 + ], + [ + 15, + 15 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 11, + 12 + ], + [ + 12, + 13 + ], + [ + 13, + 14 + ], + [ + 15, + 15 + ] + ] + }, + "text-padding": { + "stops": [ + [ + 11, + 7.7 + ], + [ + 12, + 8.4 + ], + [ + 13, + 9.8 + ], + [ + 15, + 10.5 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 11, + 1 + ], + [ + 12, + 1.12 + ], + [ + 13, + 1.15 + ], + [ + 14, + 1.17 + ], + [ + 15, + 1.2 + ] + ] + }, + "text-letter-spacing": 0.05, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": { + "stops": [ + [ + 11, + "#ffffff" + ], + [ + 14, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 2 + } + }, + { + "id": "place (low importance)", + "type": "symbol", + "source": "esri", + "source-layer": "place (low importance)", + "minzoom": 8, + "maxzoom": 15, + "layout": { + "text-font": [ + "Arial Unicode MS Regular" + ], + "text-size": { + "stops": [ + [ + 8, + 10 + ], + [ + 10, + 11 + ], + [ + 11, + 13 + ], + [ + 13, + 15 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 8, + 9 + ], + [ + 10, + 10 + ], + [ + 11, + 11 + ], + [ + 13, + 12 + ] + ] + }, + "text-padding": { + "stops": [ + [ + 8, + 7 + ], + [ + 10, + 7.7 + ], + [ + 11, + 8.4 + ], + [ + 13, + 10.5 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 8, + 1 + ], + [ + 10, + 1.13 + ], + [ + 11, + 1.16 + ], + [ + 13, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": { + "stops": [ + [ + 8, + "#ffffff" + ], + [ + 12, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "place (medium importance)", + "type": "symbol", + "source": "esri", + "source-layer": "place (medium importance)", + "minzoom": 4, + "maxzoom": 14, + "layout": { + "icon-image": "place (medium importance)", + "icon-allow-overlap": false, + "symbol-avoid-edges": true, + "text-font": [ + "Arial Unicode MS Regular" + ], + "icon-size": { + "stops": [ + [ + 5.5, + 0.8 + ], + [ + 5.6, + 1 + ] + ] + }, + "text-size": { + "stops": [ + [ + 4, + 10 + ], + [ + 8, + 12 + ], + [ + 9, + 13 + ], + [ + 10, + 14 + ], + [ + 13, + 15 + ] + ] + }, + "text-max-width": 8, + "text-padding": { + "stops": [ + [ + 4, + 7 + ], + [ + 8, + 8.4 + ], + [ + 9, + 9.1 + ], + [ + 10, + 9.8 + ], + [ + 13, + 10.5 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 4, + 1 + ], + [ + 7, + 1.12 + ], + [ + 8, + 1.14 + ], + [ + 9, + 1.16 + ], + [ + 10, + 1.2 + ] + ] + }, + "text-anchor": { + "stops": [ + [ + 9.5, + "bottom" + ], + [ + 9.6, + "center" + ] + ] + }, + "text-offset": { + "stops": [ + [ + 9.5, + [ + 0, + -0.3 + ] + ], + [ + 9.6, + [ + 0, + 0 + ] + ] + ] + }, + "text-justify": "left", + "text-field": "{_name}" + }, + "paint": { + "icon-opacity": { + "stops": [ + [ + 9.5, + 1 + ], + [ + 9.6, + 0 + ] + ] + }, + "text-color": { + "stops": [ + [ + 4, + "#ffffff" + ], + [ + 8, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "administrative label/county level 6", + "type": "symbol", + "source": "esri", + "source-layer": "administrative label/label", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 10, + "layout": { + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": 11, + "text-max-width": { + "stops": [ + [ + 10, + 14 + ], + [ + 12, + 15 + ] + ] + }, + "text-padding": { + "stops": [ + [ + 10, + 9 + ], + [ + 12, + 10.5 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 10, + 1.15 + ], + [ + 12, + 1.17 + ] + ] + }, + "text-letter-spacing": 0.2, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffd9", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "administrative label/county level 5", + "type": "symbol", + "source": "esri", + "source-layer": "administrative label/label", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 10, + "layout": { + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": 12.5, + "text-max-width": { + "stops": [ + [ + 10, + 14 + ], + [ + 12, + 15 + ] + ] + }, + "text-padding": { + "stops": [ + [ + 10, + 9 + ], + [ + 12, + 10.5 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 10, + 1.15 + ], + [ + 12, + 1.17 + ] + ] + }, + "text-letter-spacing": 0.2, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#ffffd9", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "administrative label/state", + "type": "symbol", + "source": "esri", + "source-layer": "administrative label/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 4, + "maxzoom": 10, + "layout": { + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 4, + 10 + ], + [ + 6, + 11 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 4, + 8 + ], + [ + 6, + 10 + ] + ] + }, + "text-padding": { + "stops": [ + [ + 4, + 7 + ], + [ + 6, + 7.7 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 4, + 1 + ], + [ + 6, + 1.2 + ] + ] + }, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": { + "stops": [ + [ + 5, + "#cecdcd" + ], + [ + 7, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "place (high importance)", + "type": "symbol", + "source": "esri", + "source-layer": "place (high importance)", + "minzoom": 3, + "maxzoom": 13, + "layout": { + "icon-image": "place (high importance)", + "icon-allow-overlap": false, + "symbol-avoid-edges": true, + "text-font": [ + "Arial Unicode MS Bold" + ], + "icon-size": { + "stops": [ + [ + 3, + 0.8 + ], + [ + 6, + 1 + ] + ] + }, + "text-size": { + "stops": [ + [ + 3, + 11 + ], + [ + 5, + 12 + ], + [ + 7, + 13 + ], + [ + 9, + 14 + ], + [ + 10, + 15 + ] + ] + }, + "text-max-width": 7, + "text-padding": { + "stops": [ + [ + 3, + 7.7 + ], + [ + 5, + 8.4 + ], + [ + 7, + 9.1 + ], + [ + 9, + 9.8 + ], + [ + 10, + 10.5 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 3, + 1 + ], + [ + 4, + 1.12 + ], + [ + 5, + 1.14 + ], + [ + 7, + 1.16 + ], + [ + 9, + 1.18 + ], + [ + 10, + 1.2 + ] + ] + }, + "text-anchor": { + "stops": [ + [ + 9.5, + "bottom" + ], + [ + 9.6, + "center" + ] + ] + }, + "text-offset": { + "stops": [ + [ + 9.5, + [ + 0, + -0.4 + ] + ], + [ + 9.6, + [ + 0, + 0 + ] + ] + ] + }, + "text-justify": "left", + "text-field": "{_name}" + }, + "paint": { + "icon-opacity": { + "stops": [ + [ + 9.5, + 1 + ], + [ + 9.6, + 0 + ] + ] + }, + "text-color": { + "stops": [ + [ + 3, + "#ffffff" + ], + [ + 6, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.3 + } + }, + { + "id": "capital", + "type": "symbol", + "source": "esri", + "source-layer": "capital", + "minzoom": 3, + "maxzoom": 15, + "layout": { + "icon-image": "capital", + "icon-allow-overlap": false, + "symbol-avoid-edges": true, + "icon-size": { + "stops": [ + [ + 3, + 0.8 + ], + [ + 5, + 1 + ] + ] + }, + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 3, + 11 + ], + [ + 5, + 12 + ], + [ + 7, + 13 + ], + [ + 9, + 14 + ], + [ + 10, + 15 + ] + ] + }, + "text-max-width": 6, + "text-padding": { + "stops": [ + [ + 3, + 7.7 + ], + [ + 5, + 8.4 + ], + [ + 7, + 9.1 + ], + [ + 9, + 9.8 + ], + [ + 10, + 10.5 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 3, + 1 + ], + [ + 4, + 1.12 + ], + [ + 5, + 1.14 + ], + [ + 7, + 1.16 + ], + [ + 9, + 1.18 + ], + [ + 10, + 1.2 + ] + ] + }, + "text-anchor": { + "stops": [ + [ + 9.5, + "bottom" + ], + [ + 9.6, + "center" + ] + ] + }, + "text-offset": { + "stops": [ + [ + 9.5, + [ + 0, + -0.4 + ] + ], + [ + 9.6, + [ + 0, + 0 + ] + ] + ] + }, + "text-justify": "left", + "text-field": "{_name}" + }, + "paint": { + "icon-opacity": { + "stops": [ + [ + 9.5, + 1 + ], + [ + 9.6, + 0 + ] + ] + }, + "text-color": "#ffffd9", + "text-halo-color": "#000000", + "text-halo-width": 1.3 + } + }, + { + "id": "administrative label/country", + "type": "symbol", + "source": "esri", + "source-layer": "administrative label/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "text-font": [ + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 2, + 9.5 + ], + [ + 3, + 10 + ], + [ + 4, + 11 + ], + [ + 6, + 12 + ], + [ + 9, + 13 + ] + ] + }, + "text-max-width": { + "stops": [ + [ + 2, + 6 + ], + [ + 3, + 7 + ], + [ + 4, + 8 + ], + [ + 6, + 9 + ], + [ + 9, + 10 + ] + ] + }, + "text-padding": { + "stops": [ + [ + 2, + 7 + ], + [ + 3, + 7.7 + ], + [ + 4, + 8.4 + ], + [ + 6, + 9.1 + ] + ] + }, + "text-line-height": { + "stops": [ + [ + 2, + 1.2 + ], + [ + 3, + 1.3 + ], + [ + 4, + 1.4 + ], + [ + 6, + 1.6 + ], + [ + 9, + 1.8 + ] + ] + }, + "text-letter-spacing": 0.05, + "text-field": "{_name}", + "text-transform": "uppercase", + "text-optional": true + }, + "paint": { + "text-color": { + "stops": [ + [ + 2, + "#cecdcd" + ], + [ + 7, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.3 + } + } + ] +} diff --git a/ui-ngx/src/assets/map/world_edition_hybrid_reference_style.json b/ui-ngx/src/assets/map/world_edition_hybrid_reference_style.json new file mode 100644 index 0000000000..852d0455a0 --- /dev/null +++ b/ui-ngx/src/assets/map/world_edition_hybrid_reference_style.json @@ -0,0 +1,57840 @@ +{ + "version": 8, + "sprite": "https://cdn.arcgis.com/sharing/rest/content/items/30d6b8271e1849cd9c3042060001f425/resources/sprites/sprite", + "glyphs": "https://basemaps.arcgis.com/arcgis/rest/services/World_Basemap_v2/VectorTileServer/resources/fonts/{fontstack}/{range}.pbf", + "sources": { + "esri": { + "type": "vector", + "url": "https://basemaps.arcgis.com/arcgis/rest/services/World_Basemap_v2/VectorTileServer", + "attribution": "Sources: Esri, TomTom, Garmin, FAO, NOAA, USGS, © OpenStreetMap contributors, and the GIS User Community", + "tiles": [ + "https://basemaps.arcgis.com/arcgis/rest/services/World_Basemap_v2/VectorTileServer/tile/{z}/{y}/{x}.pbf" + ] + } + }, + "layers": [ + { + "id": "Railroad/casing", + "type": "line", + "source": "esri", + "source-layer": "Railroad", + "minzoom": 12, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.4 + ], + [ + 16, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.3 + ], + [ + 17, + 5.33 + ] + ] + } + } + }, + { + "id": "Railroad/line", + "type": "line", + "source": "esri", + "source-layer": "Railroad", + "minzoom": 12, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#dbdbd0", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.4 + ], + [ + 16, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1.3 + ], + [ + 17, + 2.7 + ] + ] + } + } + }, + { + "id": "Railroad/symbol", + "type": "symbol", + "source": "esri", + "source-layer": "Railroad", + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "icon-image": "Railroad/0", + "icon-rotate": 90, + "symbol-spacing": 20, + "icon-rotation-alignment": "map", + "icon-allow-overlap": true, + "icon-padding": 1 + }, + "paint": {} + }, + { + "id": "Ferry/Rail ferry/casing", + "type": "line", + "source": "esri", + "source-layer": "Ferry", + "filter": [ + "all", + [ + "==", + "_symbol", + 1 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.4 + ], + [ + 16, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.3 + ], + [ + 17, + 5.33 + ] + ] + } + } + }, + { + "id": "Ferry/Rail ferry/line", + "type": "line", + "source": "esri", + "source-layer": "Ferry", + "filter": [ + "all", + [ + "==", + "_symbol", + 1 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#dbdbd0", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.4 + ], + [ + 16, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1.3 + ], + [ + 17, + 2.7 + ] + ] + } + } + }, + { + "id": "Ferry/Rail ferry/symbol", + "type": "symbol", + "source": "esri", + "source-layer": "Ferry", + "filter": [ + "all", + [ + "==", + "_symbol", + 1 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "icon-image": "Railroad/0", + "symbol-spacing": 20, + "icon-rotation-alignment": "map", + "icon-allow-overlap": true, + "icon-rotate": 90, + "icon-padding": 1 + }, + "paint": {} + }, + { + "id": "Road/4WD/casing", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 10 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 21 + ], + [ + 22, + 36 + ] + ] + } + } + }, + { + "id": "Road/Service/casing", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 8 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 23 + ], + [ + 22, + 38 + ] + ] + } + } + }, + { + "id": "Road/Local/casing", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 7 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 3.33 + ], + [ + 15, + 4 + ], + [ + 16, + 4.67 + ], + [ + 17, + 7.3 + ], + [ + 18, + 25 + ], + [ + 22, + 40 + ] + ] + } + } + }, + { + "id": "Road/4WD/line", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 10 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fdfdfd", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 18 + ], + [ + 22, + 33 + ] + ] + } + } + }, + { + "id": "Road/Service/line", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 8 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 20 + ], + [ + 22, + 35 + ] + ] + } + } + }, + { + "id": "Road/Local/line", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 7 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 13, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#FDFDFD", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 13, + 0.25 + ], + [ + 17, + 0.2 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1.33 + ], + [ + 15, + 2 + ], + [ + 16, + 2.67 + ], + [ + 17, + 5.3 + ], + [ + 18, + 22 + ], + [ + 22, + 37 + ] + ] + } + } + }, + { + "id": "Road/Minor/casing", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 5 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 27 + ], + [ + 22, + 42 + ] + ] + } + } + }, + { + "id": "Road/Minor, ramp or traffic circle/casing", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 6 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 17 + ], + [ + 22, + 32 + ] + ] + } + } + }, + { + "id": "Road/Minor/line", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 5 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 24 + ], + [ + 22, + 39 + ] + ] + } + } + }, + { + "id": "Road/Minor, ramp or traffic circle/line", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 6 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 14 + ], + [ + 22, + 29 + ] + ] + } + } + }, + { + "id": "Road/Major/casing", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 3 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 29 + ], + [ + 22, + 44 + ] + ] + } + } + }, + { + "id": "Road/Major, ramp or traffic circle/casing", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 4 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 19 + ], + [ + 22, + 34 + ] + ] + } + } + }, + { + "id": "Road/Major/line", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 3 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 26 + ], + [ + 22, + 41 + ] + ] + } + } + }, + { + "id": "Road/Major, ramp or traffic circle/line", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 4 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 10, + 0.2 + ], + [ + 11, + 0.3 + ], + [ + 12, + 0.45 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.3 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 16 + ], + [ + 22, + 31 + ] + ] + } + } + }, + { + "id": "Road/Highway/casing", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 1 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 6, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 31 + ], + [ + 22, + 46 + ] + ] + } + } + }, + { + "id": "Road/Freeway Motorway/casing", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 0 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 6, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 33 + ], + [ + 22, + 48 + ] + ] + } + } + }, + { + "id": "Road/Freeway Motorway Highway, ramp or traffic circle/casing", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 2 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 6, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.1 + ], + [ + 13, + 0.12 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 23 + ], + [ + 22, + 38 + ] + ] + } + } + }, + { + "id": "Road/Highway/line", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 1 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 6, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 28 + ], + [ + 22, + 42 + ] + ] + } + } + }, + { + "id": "Road/Freeway Motorway/line", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 0 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 6, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.15 + ], + [ + 9, + 0.2 + ], + [ + 12, + 0.23 + ], + [ + 13, + 0.35 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 30 + ], + [ + 22, + 45 + ] + ] + } + } + }, + { + "id": "Road/Freeway Motorway Highway, ramp or traffic circle/line", + "type": "line", + "source": "esri", + "source-layer": "Road", + "filter": [ + "all", + [ + "==", + "_symbol", + 2 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 6, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 6, + "#ffb994" + ], + [ + 10, + "#ff9961" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 6, + 0.1 + ], + [ + 13, + 0.12 + ], + [ + 17, + 0.25 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 6, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 20 + ], + [ + 22, + 35 + ] + ] + } + } + }, + { + "id": "Road tunnel/Minor/casing", + "type": "line", + "source": "esri", + "source-layer": "Road tunnel", + "filter": [ + "all", + [ + "==", + "_symbol", + 5 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.35 + ], + [ + 13, + 0.25 + ], + [ + 17, + 0.15 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 27 + ], + [ + 22, + 42 + ] + ] + } + } + }, + { + "id": "Road tunnel/Minor, ramp or traffic circle/casing", + "type": "line", + "source": "esri", + "source-layer": "Road tunnel", + "filter": [ + "all", + [ + "==", + "_symbol", + 6 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.35 + ], + [ + 13, + 0.25 + ], + [ + 17, + 0.15 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 17 + ], + [ + 22, + 32 + ] + ] + } + } + }, + { + "id": "Road tunnel/Minor/line", + "type": "line", + "source": "esri", + "source-layer": "Road tunnel", + "filter": [ + "all", + [ + "==", + "_symbol", + 5 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.35 + ], + [ + 13, + 0.25 + ], + [ + 17, + 0.15 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 24 + ], + [ + 22, + 39 + ] + ] + } + } + }, + { + "id": "Road tunnel/Minor, ramp or traffic circle/line", + "type": "line", + "source": "esri", + "source-layer": "Road tunnel", + "filter": [ + "all", + [ + "==", + "_symbol", + 6 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.35 + ], + [ + 13, + 0.25 + ], + [ + 17, + 0.15 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 14 + ], + [ + 22, + 29 + ] + ] + } + } + }, + { + "id": "Road tunnel/Major/casing", + "type": "line", + "source": "esri", + "source-layer": "Road tunnel", + "filter": [ + "all", + [ + "==", + "_symbol", + 3 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.35 + ], + [ + 13, + 0.25 + ], + [ + 17, + 0.15 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 29 + ], + [ + 22, + 44 + ] + ] + } + } + }, + { + "id": "Road tunnel/Major, ramp or traffic circle/casing", + "type": "line", + "source": "esri", + "source-layer": "Road tunnel", + "filter": [ + "all", + [ + "==", + "_symbol", + 4 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#191919", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.35 + ], + [ + 13, + 0.25 + ], + [ + 17, + 0.15 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 14, + 4 + ], + [ + 15, + 4.67 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 19 + ], + [ + 22, + 34 + ] + ] + } + } + }, + { + "id": "Road tunnel/Major/line", + "type": "line", + "source": "esri", + "source-layer": "Road tunnel", + "filter": [ + "all", + [ + "==", + "_symbol", + 3 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.35 + ], + [ + 13, + 0.25 + ], + [ + 17, + 0.15 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 26 + ], + [ + 22, + 41 + ] + ] + } + } + }, + { + "id": "Road tunnel/Major, ramp or traffic circle/line", + "type": "line", + "source": "esri", + "source-layer": "Road tunnel", + "filter": [ + "all", + [ + "==", + "_symbol", + 4 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1.2, + "stops": [ + [ + 12, + "#fffaf0" + ], + [ + 13, + "#ffdb8c" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.35 + ], + [ + 13, + 0.25 + ], + [ + 17, + 0.15 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1.33 + ], + [ + 14, + 2 + ], + [ + 15, + 2.67 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 16 + ], + [ + 22, + 31 + ] + ] + } + } + }, + { + "id": "Road tunnel/Highway/casing", + "type": "line", + "source": "esri", + "source-layer": "Road tunnel", + "filter": [ + "all", + [ + "==", + "_symbol", + 1 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 8, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.35 + ], + [ + 13, + 0.25 + ], + [ + 17, + 0.15 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 31 + ], + [ + 22, + 46 + ] + ] + } + } + }, + { + "id": "Road tunnel/Freeway Motorway/casing", + "type": "line", + "source": "esri", + "source-layer": "Road tunnel", + "filter": [ + "all", + [ + "==", + "_symbol", + 0 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 8, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.35 + ], + [ + 13, + 0.25 + ], + [ + 17, + 0.15 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 33 + ], + [ + 22, + 48 + ] + ] + } + } + }, + { + "id": "Road tunnel/Freeway Motorway Highway, ramp or traffic circle/casing", + "type": "line", + "source": "esri", + "source-layer": "Road tunnel", + "filter": [ + "all", + [ + "==", + "_symbol", + 2 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#4d0800", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.35 + ], + [ + 13, + 0.25 + ], + [ + 17, + 0.15 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 3.33 + ], + [ + 13, + 4 + ], + [ + 15, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 23 + ], + [ + 22, + 38 + ] + ] + } + } + }, + { + "id": "Road tunnel/Highway/line", + "type": "line", + "source": "esri", + "source-layer": "Road tunnel", + "filter": [ + "all", + [ + "==", + "_symbol", + 1 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 8, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#ff9961", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.35 + ], + [ + 13, + 0.25 + ], + [ + 17, + 0.15 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 28 + ], + [ + 22, + 42 + ] + ] + } + } + }, + { + "id": "Road tunnel/Freeway Motorway/line", + "type": "line", + "source": "esri", + "source-layer": "Road tunnel", + "filter": [ + "all", + [ + "==", + "_symbol", + 0 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 8, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#ff9961", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.35 + ], + [ + 13, + 0.25 + ], + [ + 17, + 0.15 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 30 + ], + [ + 22, + 45 + ] + ] + } + } + }, + { + "id": "Road tunnel/Freeway Motorway Highway, ramp or traffic circle/line", + "type": "line", + "source": "esri", + "source-layer": "Road tunnel", + "filter": [ + "all", + [ + "==", + "_symbol", + 2 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#ff9961", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 12, + 0.35 + ], + [ + 13, + 0.25 + ], + [ + 17, + 0.15 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1.33 + ], + [ + 13, + 2 + ], + [ + 15, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 20 + ], + [ + 22, + 35 + ] + ] + } + } + }, + { + "id": "Boundary line/Disputed admin2", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 8 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-opacity": 0.55, + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.6 + ], + [ + 11, + 2.5 + ], + [ + 18, + 4 + ] + ] + }, + "line-dasharray": [ + 6, + 3 + ] + } + }, + { + "id": "Boundary line/Disputed admin1", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 7 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 3, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#000000", + "line-dasharray": [ + 6.0, + 3.0 + ], + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 3, + 0.7 + ], + [ + 10, + 0.55 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 3, + 1.6 + ], + [ + 11, + 2.5 + ], + [ + 18, + 4 + ] + ] + } + } + }, + { + "id": "Boundary line/Disputed admin0", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 6 + ], + [ + "!in", + "Viz", + 3 + ], + [ + "!in", + "DisputeID", + 8, + 16, + 90, + 96, + 0 + ] + ], + "minzoom": 1, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": { + "stops": [ + [ + 1, + "#ffffff" + ], + [ + 2, + "#000000" + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 1, + 1.33 + ], + [ + 2, + 1.6 + ], + [ + 11, + 2.5 + ], + [ + 18, + 4 + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 1, + 0.55 + ], + [ + 4, + 0.7 + ], + [ + 10, + 0.55 + ] + ] + }, + "line-dasharray": [ + 6, + 3 + ] + } + }, + { + "id": "Boundary line/Admin0/casing", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 0 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 2, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": { + "stops": [ + [ + 2, + "#000000" + ], + [ + 10, + "#4e4e4e" + ], + [ + 11, + "#cccccc" + ] + ] + }, + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 2, + 0.55 + ], + [ + 4, + 0.7 + ], + [ + 10, + 0.55 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 2, + 3.33 + ], + [ + 6, + 5 + ], + [ + 18, + 12 + ] + ] + } + } + }, + { + "id": "Boundary line/Admin2", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 2 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#ffffff", + "line-opacity": 0.55, + "line-dasharray": [ + 2, + 2 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 1.5 + ], + [ + 13, + 2 + ], + [ + 18, + 2.75 + ] + ] + } + } + }, + { + "id": "Boundary line/Admin1", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 1 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 3, + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#ffffff", + "line-dasharray": [ + 4, + 3 + ], + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 3, + 0.55 + ], + [ + 4, + 0.7 + ], + [ + 10, + 0.55 + ] + ] + }, + "line-width": { + "base": 1.0, + "stops": [ + [ + 3, + 1.33 + ], + [ + 6, + 1.75 + ], + [ + 18, + 4 + ] + ] + } + } + }, + { + "id": "Boundary line/Admin0/line", + "type": "line", + "source": "esri", + "source-layer": "Boundary line", + "filter": [ + "all", + [ + "==", + "_symbol", + 0 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 1, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#ffffff", + "line-opacity": { + "base": 1.0, + "stops": [ + [ + 1, + 0.55 + ], + [ + 4, + 0.7 + ], + [ + 10, + 0.55 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 1, + 1.33 + ], + [ + 6, + 2 + ], + [ + 18, + 4 + ] + ] + } + } + }, + { + "id": "Water point/Stream or river", + "type": "symbol", + "source": "esri", + "source-layer": "Water point", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 9, + "layout": { + "icon-image": "Water point", + "icon-allow-overlap": true, + "text-font": [ + "Arial Italic" + ], + "text-size": { + "stops": [ + [ + 9, + 7 + ], + [ + 15, + 11.3 + ] + ] + }, + "text-anchor": "center", + "text-letter-spacing": 0.01, + "text-max-width": 5, + "text-field": "{_name_global}", + "text-padding": 15 + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Water point/Lake or reservoir", + "type": "symbol", + "source": "esri", + "source-layer": "Water point", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 9, + "layout": { + "icon-image": "Water point", + "icon-allow-overlap": true, + "text-font": [ + "Arial Italic" + ], + "text-size": { + "stops": [ + [ + 9, + 7 + ], + [ + 15, + 11.3 + ] + ] + }, + "text-anchor": "center", + "text-letter-spacing": 0.01, + "text-max-width": 5, + "text-field": "{_name_global}", + "text-padding": 15 + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Water point/Bay or inlet", + "type": "symbol", + "source": "esri", + "source-layer": "Water point", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 9, + "layout": { + "icon-image": "Water point", + "icon-allow-overlap": true, + "text-font": [ + "Arial Italic" + ], + "text-size": { + "stops": [ + [ + 9, + 7 + ], + [ + 15, + 12 + ] + ] + }, + "text-anchor": "center", + "text-letter-spacing": 0.15, + "text-max-width": 5, + "text-field": "{_name_global}", + "text-padding": 15 + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Water point/Sea or ocean", + "type": "symbol", + "source": "esri", + "source-layer": "Water point", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 9, + "layout": { + "icon-image": "Water point", + "icon-allow-overlap": true, + "text-font": [ + "Arial Italic" + ], + "text-size": 15, + "text-anchor": "center", + "text-letter-spacing": 0.1, + "text-max-width": 7, + "text-field": "{_name_global}", + "text-padding": 15 + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Water point/Canal or ditch", + "type": "symbol", + "source": "esri", + "source-layer": "Water point", + "filter": [ + "==", + "_label_class", + 4 + ], + "minzoom": 11, + "layout": { + "icon-image": "Water point", + "icon-allow-overlap": true, + "text-font": [ + "Arial Italic" + ], + "text-size": { + "stops": [ + [ + 11, + 7 + ], + [ + 15, + 10 + ] + ] + }, + "text-anchor": "center", + "text-letter-spacing": 0.01, + "text-max-width": 5, + "text-field": "{_name_global}", + "text-padding": 15 + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Water point/Island", + "type": "symbol", + "source": "esri", + "source-layer": "Water point", + "filter": [ + "==", + "_label_class", + 7 + ], + "minzoom": 11, + "layout": { + "icon-image": "Water point", + "icon-allow-overlap": true, + "text-font": [ + "Arial Italic" + ], + "text-size": { + "stops": [ + [ + 11, + 7 + ], + [ + 15, + 11.3 + ] + ] + }, + "text-anchor": "center", + "text-letter-spacing": 0.05, + "text-max-width": 5, + "text-field": "{_name_global}", + "text-padding": 15 + }, + "paint": { + "text-color": "#d9d1ba", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Water line/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Water line/label", + "minzoom": 14, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-size": 10, + "text-letter-spacing": 0.07, + "text-max-width": 8, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Marine park/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Marine park/label", + "minzoom": 11, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": { + "base": 1.2, + "stops": [ + [ + 6, + 10 + ], + [ + 7, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + }, + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#d9fff2", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Water area/label/Canal or ditch", + "type": "symbol", + "source": "esri", + "source-layer": "Water area/label", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 11, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 1000, + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-size": 10, + "text-letter-spacing": 0.01, + "text-max-width": 5, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Water area/label/Small river", + "type": "symbol", + "source": "esri", + "source-layer": "Water area/label", + "filter": [ + "==", + "_label_class", + 7 + ], + "minzoom": 11, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 1000, + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-size": 11.33, + "text-letter-spacing": 0.01, + "text-max-width": 5, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Water area/label/Large river", + "type": "symbol", + "source": "esri", + "source-layer": "Water area/label", + "filter": [ + "==", + "_label_class", + 4 + ], + "minzoom": 11, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 1000, + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-size": 13.33, + "text-letter-spacing": 0.01, + "text-max-width": 7, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Water area/label/Small lake or reservoir", + "type": "symbol", + "source": "esri", + "source-layer": "Water area/label", + "filter": [ + "==", + "_label_class", + 6 + ], + "minzoom": 11, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-size": 11.33, + "text-letter-spacing": 0.01, + "text-max-width": 5, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Water area/label/Large lake or reservoir", + "type": "symbol", + "source": "esri", + "source-layer": "Water area/label", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 11, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-size": 13.33, + "text-letter-spacing": 0.01, + "text-max-width": 7, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Water area/label/Bay or inlet", + "type": "symbol", + "source": "esri", + "source-layer": "Water area/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 11, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-size": 12, + "text-letter-spacing": 0.15, + "text-max-width": 7, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Water area/label/Small island", + "type": "symbol", + "source": "esri", + "source-layer": "Water area/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 11, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-size": 11.3, + "text-letter-spacing": 0.05, + "text-max-width": 5, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#d9d1ba", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Water area/label/Large island", + "type": "symbol", + "source": "esri", + "source-layer": "Water area/label", + "filter": [ + "==", + "_label_class", + 5 + ], + "minzoom": 11, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-size": 12.5, + "text-letter-spacing": 0.05, + "text-max-width": 7, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#d9d1ba", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Water area large scale/label/River", + "type": "symbol", + "source": "esri", + "source-layer": "Water area large scale/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 7, + "maxzoom": 11, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 1000, + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-size": 11, + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name}" + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Water area large scale/label/Lake or lake intermittent", + "type": "symbol", + "source": "esri", + "source-layer": "Water area large scale/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 7, + "maxzoom": 11, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-size": 11, + "text-letter-spacing": 0.05, + "text-max-width": 5, + "text-field": "{_name}" + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Water area medium scale/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Water area medium scale/label", + "minzoom": 5, + "maxzoom": 7, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-max-width": 5, + "text-field": "{_name}", + "text-letter-spacing": 0.05, + "text-size": { + "base": 1.2, + "stops": [ + [ + 5, + 10 + ], + [ + 6, + 11 + ] + ] + } + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Water area small scale/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Water area small scale/label", + "minzoom": 3, + "maxzoom": 5, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-letter-spacing": 0.05, + "text-max-width": 5, + "text-field": "{_name}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 3, + 9 + ], + [ + 4, + 10 + ] + ] + } + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Marine area/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Marine area/label", + "minzoom": 11, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-size": 10, + "text-max-width": 8, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Marine waterbody/label/small", + "type": "symbol", + "source": "esri", + "source-layer": "Marine waterbody/label", + "filter": [ + "==", + "_label_class", + 4 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-size": 11, + "text-letter-spacing": 0.1, + "text-max-width": 6, + "text-field": "{_name}", + "text-padding": 15 + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Marine waterbody/label/medium", + "type": "symbol", + "source": "esri", + "source-layer": "Marine waterbody/label", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-size": 11, + "text-letter-spacing": 0.1, + "text-max-width": 6, + "text-field": "{_name}", + "text-padding": 15 + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Marine waterbody/label/large", + "type": "symbol", + "source": "esri", + "source-layer": "Marine waterbody/label", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-letter-spacing": 0.1, + "text-max-width": 6, + "text-field": "{_name}", + "text-padding": 15, + "text-size": { + "base": 1.2, + "stops": [ + [ + 2, + 11 + ], + [ + 6, + 13 + ], + [ + 9, + 15 + ] + ] + } + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Marine waterbody/label/x large", + "type": "symbol", + "source": "esri", + "source-layer": "Marine waterbody/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-letter-spacing": 0.1, + "text-max-width": 6, + "text-field": "{_name}", + "text-padding": 15, + "text-size": { + "base": 1.2, + "stops": [ + [ + 2, + 11 + ], + [ + 4, + 13 + ], + [ + 9, + 15 + ] + ] + } + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Marine waterbody/label/2x large", + "type": "symbol", + "source": "esri", + "source-layer": "Marine waterbody/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Italic" + ], + "text-letter-spacing": 0.1, + "text-max-width": 6, + "text-field": "{_name}", + "text-padding": 15, + "text-size": { + "base": 1.2, + "stops": [ + [ + 0, + 10 + ], + [ + 1, + 12 + ], + [ + 2, + 13 + ], + [ + 9, + 15 + ] + ] + } + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-width": 1, + "text-halo-color": "#000000" + } + }, + { + "id": "Ferry/label/Rail ferry", + "type": "symbol", + "source": "esri", + "source-layer": "Ferry/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 1 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 15, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.1, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-offset": [ + 0, + -0.6 + ], + "text-size": { + "base": 1.2, + "stops": [ + [ + 15, + 11 + ], + [ + 17, + 11.3 + ] + ] + } + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Railroad/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Railroad/label", + "minzoom": 15, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "symbol-spacing": 1000, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.1, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-padding": 10, + "text-offset": [ + 0, + -0.6 + ], + "text-size": { + "base": 1.2, + "stops": [ + [ + 15, + 11 + ], + [ + 17, + 11.3 + ] + ] + } + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Place/POI Other/Color6", + "type": "symbol", + "source": "esri", + "source-layer": "POI Other place", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "POI Other place/Color6_1" + ], + [ + 16.6, + "POI Other place/Color6" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/POI Other/Color5", + "type": "symbol", + "source": "esri", + "source-layer": "POI Other place", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "POI Other place/Color5_1" + ], + [ + 16.6, + "POI Other place/Color5" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/POI Other/Color4", + "type": "symbol", + "source": "esri", + "source-layer": "POI Other place", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "POI Other place/Color4_1" + ], + [ + 16.6, + "POI Other place/Color4" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/POI Other/Color3", + "type": "symbol", + "source": "esri", + "source-layer": "POI Other place", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "POI Other place/Color3_1" + ], + [ + 16.6, + "POI Other place/Color3" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/POI Other/Color2", + "type": "symbol", + "source": "esri", + "source-layer": "POI Other place", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "POI Other place/Color2_1" + ], + [ + 16.6, + "POI Other place/Color2" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/POI Other/Color1", + "type": "symbol", + "source": "esri", + "source-layer": "POI Other place", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "POI Other place/Color1_1" + ], + [ + 16.6, + "POI Other place/Color1" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Unclassified", + "type": "symbol", + "source": "esri", + "source-layer": "Unclassified place", + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Unclassified place/Unclassified_1" + ], + [ + 16.6, + "Unclassified place/Unclassified" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Wharf", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 26 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Wharf_1" + ], + [ + 16.6, + "Water Feature place/Wharf" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Well", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 25 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Well_1" + ], + [ + 16.6, + "Water Feature place/Well" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Waterfall", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 24 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Waterfall_1" + ], + [ + 16.6, + "Water Feature place/Waterfall" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Stream", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 23 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Stream_1" + ], + [ + 16.6, + "Water Feature place/Stream" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Strait", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 22 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Strait_1" + ], + [ + 16.6, + "Water Feature place/Strait" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Spring", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 21 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Spring_1" + ], + [ + 16.6, + "Water Feature place/Spring" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Sound", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 20 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Sound_1" + ], + [ + 16.6, + "Water Feature place/Sound" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Sea", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 19 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Sea_1" + ], + [ + 16.6, + "Water Feature place/Sea" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Reservoir", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 18 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Reservoir_1" + ], + [ + 16.6, + "Water Feature place/Reservoir" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Reef", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 17 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Reef_1" + ], + [ + 16.6, + "Water Feature place/Reef" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Other Water Feature", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 16 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Other Water Feature_1" + ], + [ + 16.6, + "Water Feature place/Other Water Feature" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Ocean", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 15 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Ocean_1" + ], + [ + 16.6, + "Water Feature place/Ocean" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Lake", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 14 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Lake_1" + ], + [ + 16.6, + "Water Feature place/Lake" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Lagoon", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 13 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Lagoon_1" + ], + [ + 16.6, + "Water Feature place/Lagoon" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Jetty", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 12 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Jetty_1" + ], + [ + 16.6, + "Water Feature place/Jetty" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Irrigation", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 11 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Irrigation_1" + ], + [ + 16.6, + "Water Feature place/Irrigation" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Hot Spring", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 10 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Hot Spring_1" + ], + [ + 16.6, + "Water Feature place/Hot Spring" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Harbor", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Harbor_1" + ], + [ + 16.6, + "Water Feature place/Harbor" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Gulf", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Gulf_1" + ], + [ + 16.6, + "Water Feature place/Gulf" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Fjord", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Fjord_1" + ], + [ + 16.6, + "Water Feature place/Fjord" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Estuary", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Estuary_1" + ], + [ + 16.6, + "Water Feature place/Estuary" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Delta", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Delta_1" + ], + [ + 16.6, + "Water Feature place/Delta" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Dam", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Dam_1" + ], + [ + 16.6, + "Water Feature place/Dam" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Cove", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Cove_1" + ], + [ + 16.6, + "Water Feature place/Cove" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Channel", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Channel_1" + ], + [ + 16.6, + "Water Feature place/Channel" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Canal", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Canal_1" + ], + [ + 16.6, + "Water Feature place/Canal" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Water Feature/Bay", + "type": "symbol", + "source": "esri", + "source-layer": "Water Feature place", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Water Feature place/Bay_1" + ], + [ + 16.6, + "Water Feature place/Bay" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Wetland", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 35 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Wetland_1" + ], + [ + 16.6, + "Land Feature place/Wetland" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Volcano", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 34 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Volcano_1" + ], + [ + 16.6, + "Land Feature place/Volcano" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Valley", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 33 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Valley_1" + ], + [ + 16.6, + "Land Feature place/Valley" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Swamp", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 32 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Swamp_1" + ], + [ + 16.6, + "Land Feature place/Swamp" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Scrubland", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 31 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Scrubland_1" + ], + [ + 16.6, + "Land Feature place/Scrubland" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Rock", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 30 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Rock_1" + ], + [ + 16.6, + "Land Feature place/Rock" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Ridge", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 29 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Ridge_1" + ], + [ + 16.6, + "Land Feature place/Ridge" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Ravine", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 28 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Ravine_1" + ], + [ + 16.6, + "Land Feature place/Ravine" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Point", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 27 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Point_1" + ], + [ + 16.6, + "Land Feature place/Point" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Plateau", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 26 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Plateau_1" + ], + [ + 16.6, + "Land Feature place/Plateau" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Plain", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 25 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Plain_1" + ], + [ + 16.6, + "Land Feature place/Plain" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Peninsula", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 24 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Peninsula_1" + ], + [ + 16.6, + "Land Feature place/Peninsula" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Other Land Feature", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 23 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Other Land Feature_1" + ], + [ + 16.6, + "Land Feature place/Other Land Feature" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Oasis", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 22 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Oasis_1" + ], + [ + 16.6, + "Land Feature place/Oasis" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Mountain Range", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 21 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Mountain Range_1" + ], + [ + 16.6, + "Land Feature place/Mountain Range" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Mountain", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 20 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Mountain_1" + ], + [ + 16.6, + "Land Feature place/Mountain" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Mesa", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 19 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Mesa_1" + ], + [ + 16.6, + "Land Feature place/Mesa" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Meadow", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 18 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Meadow_1" + ], + [ + 16.6, + "Land Feature place/Meadow" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Marsh", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 17 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Marsh_1" + ], + [ + 16.6, + "Land Feature place/Marsh" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Lava", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 16 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Lava_1" + ], + [ + 16.6, + "Land Feature place/Lava" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Isthmus", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 15 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Isthmus_1" + ], + [ + 16.6, + "Land Feature place/Isthmus" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Island", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 14 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Island_1" + ], + [ + 16.6, + "Land Feature place/Island" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Hill", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 13 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Hill_1" + ], + [ + 16.6, + "Land Feature place/Hill" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Grassland", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 12 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Grassland_1" + ], + [ + 16.6, + "Land Feature place/Grassland" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Glacier", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 11 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Glacier_1" + ], + [ + 16.6, + "Land Feature place/Glacier" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Forest", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 10 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Forest_1" + ], + [ + 16.6, + "Land Feature place/Forest" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Flat", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Flat_1" + ], + [ + 16.6, + "Land Feature place/Flat" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Dune", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Dune_1" + ], + [ + 16.6, + "Land Feature place/Dune" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Desert", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Desert_1" + ], + [ + 16.6, + "Land Feature place/Desert" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Cliff", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Cliff_1" + ], + [ + 16.6, + "Land Feature place/Cliff" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Cave", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Cave_1" + ], + [ + 16.6, + "Land Feature place/Cave" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Cape", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Cape_1" + ], + [ + 16.6, + "Land Feature place/Cape" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Canyon", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Canyon_1" + ], + [ + 16.6, + "Land Feature place/Canyon" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Butte", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Butte_1" + ], + [ + 16.6, + "Land Feature place/Butte" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Basin", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Basin_1" + ], + [ + 16.6, + "Land Feature place/Basin" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Land Feature/Atoll", + "type": "symbol", + "source": "esri", + "source-layer": "Land Feature place", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Land Feature place/Atoll_1" + ], + [ + 16.6, + "Land Feature place/Atoll" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Residence/Trailer Park", + "type": "symbol", + "source": "esri", + "source-layer": "Residence place", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Residence place/Trailer Park_1" + ], + [ + 16.6, + "Residence place/Trailer Park" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Residence/Other Residence", + "type": "symbol", + "source": "esri", + "source-layer": "Residence place", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Residence place/Other Residence_1" + ], + [ + 16.6, + "Residence place/Other Residence" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Residence/Nursing Home", + "type": "symbol", + "source": "esri", + "source-layer": "Residence place", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Residence place/Nursing Home_1" + ], + [ + 16.6, + "Residence place/Nursing Home" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Residence/House", + "type": "symbol", + "source": "esri", + "source-layer": "Residence place", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Residence place/House_1" + ], + [ + 16.6, + "Residence place/House" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Residence/Estate", + "type": "symbol", + "source": "esri", + "source-layer": "Residence place", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Residence place/Estate_1" + ], + [ + 16.6, + "Residence place/Estate" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Residence/Construction", + "type": "symbol", + "source": "esri", + "source-layer": "Residence place", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Residence place/Construction_1" + ], + [ + 16.6, + "Residence place/Construction" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Residence/Apartment Rental", + "type": "symbol", + "source": "esri", + "source-layer": "Residence place", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Residence place/Apartment Rental_1" + ], + [ + 16.6, + "Residence place/Apartment Rental" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Religion/Other Religion", + "type": "symbol", + "source": "esri", + "source-layer": "Religion place", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Religion place/Other Religion_1" + ], + [ + 16.6, + "Religion place/Other Religion" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Religion/Temple", + "type": "symbol", + "source": "esri", + "source-layer": "Religion place", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Religion place/Temple_1" + ], + [ + 16.6, + "Religion place/Temple" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Religion/Synagogue", + "type": "symbol", + "source": "esri", + "source-layer": "Religion place", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Religion place/Synagogue_1" + ], + [ + 16.6, + "Religion place/Synagogue" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Religion/Mosque", + "type": "symbol", + "source": "esri", + "source-layer": "Religion place", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Religion place/Mosque_1" + ], + [ + 16.6, + "Religion place/Mosque" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Religion/Gurdwara", + "type": "symbol", + "source": "esri", + "source-layer": "Religion place", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Religion place/Gurdwara_1" + ], + [ + 16.6, + "Religion place/Gurdwara" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Religion/Ashram", + "type": "symbol", + "source": "esri", + "source-layer": "Religion place", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Religion place/Ashram_1" + ], + [ + 16.6, + "Religion place/Ashram" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Religion/Religious Center", + "type": "symbol", + "source": "esri", + "source-layer": "Religion place", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Religion place/Religious Center_1" + ], + [ + 16.6, + "Religion place/Religious Center" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Religion/Place of Worship", + "type": "symbol", + "source": "esri", + "source-layer": "Religion place", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Religion place/Place of Worship_1" + ], + [ + 16.6, + "Religion place/Place of Worship" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Other Infrastructure", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 16 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Other Infrastructure_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Other Infrastructure" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Other Industrial", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 15 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Other Industrial_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Other Industrial" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Other Communication", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 14 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Other Communication_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Other Communication" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Industrial Zone", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Industrial Zone_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Industrial Zone" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Construction", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Construction_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Construction" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Mine", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 11 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Mine_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Mine" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Distillery", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Distillery_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Distillery" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Water Treatment", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 31 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Water Treatment_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Water Treatment" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Water Tank", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 30 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Water Tank_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Water Tank" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Logging", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 10 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Logging_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Logging" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Ranch", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 21 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Ranch_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Ranch" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Farm", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Farm_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Farm" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Food Production", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Food Production_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Food Production" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Livestock", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Livestock_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Livestock" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Plantation", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 17 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Plantation_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Plantation" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Vineyard", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 27 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Vineyard_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Vineyard" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Orchard", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 13 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Orchard_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Orchard" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Distribution Center", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Distribution Center_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Distribution Center" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Warehouse", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 28 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Warehouse_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Warehouse" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Factory", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Factory_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Factory" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Waste Management", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 29 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Waste Management_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Waste Management" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Recycling Center", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 22 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Recycling Center_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Recycling Center" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Oil and Gas Facility", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 12 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Oil and Gas Facility_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Oil and Gas Facility" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Utilities", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 26 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Utilities_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Utilities" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Radio Station", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 19 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Radio Station_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Radio Station" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/TV Station", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 25 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/TV Station_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/TV Station" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Power Station", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 18 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Power Station_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Power Station" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Radio Tower", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 20 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Radio Tower_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Radio Tower" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Tower", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 24 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Tower_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Tower" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Telecom", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 23 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Telecom_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Telecom" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Communication", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Communication_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Communication" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Industrial or Infrastructure/Building", + "type": "symbol", + "source": "esri", + "source-layer": "Industrial or Infrastructure place", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Industrial or Infrastructure place/Building_1" + ], + [ + 16.6, + "Industrial or Infrastructure place/Building" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Lodging/Other Lodging", + "type": "symbol", + "source": "esri", + "source-layer": "Lodging place", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Lodging place/Other Lodging_1" + ], + [ + 16.6, + "Lodging place/Other Lodging" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Lodging/Guest House", + "type": "symbol", + "source": "esri", + "source-layer": "Lodging place", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Lodging place/Guest House_1" + ], + [ + 16.6, + "Lodging place/Guest House" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Lodging/Bed and Breakfast", + "type": "symbol", + "source": "esri", + "source-layer": "Lodging place", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Lodging place/Bed and Breakfast_1" + ], + [ + 16.6, + "Lodging place/Bed and Breakfast" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Lodging/Hostel", + "type": "symbol", + "source": "esri", + "source-layer": "Lodging place", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Lodging place/Hostel_1" + ], + [ + 16.6, + "Lodging place/Hostel" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Lodging/Motel", + "type": "symbol", + "source": "esri", + "source-layer": "Lodging place", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Lodging place/Motel_1" + ], + [ + 16.6, + "Lodging place/Motel" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Education/Other Education", + "type": "symbol", + "source": "esri", + "source-layer": "Education place", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Education place/Other Education_1" + ], + [ + 16.6, + "Education place/Other Education" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Education/Exam Preparation and Testing", + "type": "symbol", + "source": "esri", + "source-layer": "Education place", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Education place/Exam Preparation and Testing_1" + ], + [ + 16.6, + "Education place/Exam Preparation and Testing" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Education/Coaching Institute", + "type": "symbol", + "source": "esri", + "source-layer": "Education place", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Education place/Coaching Institute_1" + ], + [ + 16.6, + "Education place/Coaching Institute" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Education/Scientific Research", + "type": "symbol", + "source": "esri", + "source-layer": "Education place", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Education place/Scientific Research_1" + ], + [ + 16.6, + "Education place/Scientific Research" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Education/Research Station", + "type": "symbol", + "source": "esri", + "source-layer": "Education place", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Education place/Research Station_1" + ], + [ + 16.6, + "Education place/Research Station" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Education/Language Studies", + "type": "symbol", + "source": "esri", + "source-layer": "Education place", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Education place/Language Studies_1" + ], + [ + 16.6, + "Education place/Language Studies" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Education/Kindergarten", + "type": "symbol", + "source": "esri", + "source-layer": "Education place", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Education place/Kindergarten_1" + ], + [ + 16.6, + "Education place/Kindergarten" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Education/Fine Arts School", + "type": "symbol", + "source": "esri", + "source-layer": "Education place", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Education place/Fine Arts School_1" + ], + [ + 16.6, + "Education place/Fine Arts School" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Education/Vocational School", + "type": "symbol", + "source": "esri", + "source-layer": "Education place", + "filter": [ + "==", + "_symbol", + 10 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Education place/Vocational School_1" + ], + [ + 16.6, + "Education place/Vocational School" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Medical/Other Medical", + "type": "symbol", + "source": "esri", + "source-layer": "Medical place", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Medical place/Other Medical_1" + ], + [ + 16.6, + "Medical place/Other Medical" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Medical/Veterinarian", + "type": "symbol", + "source": "esri", + "source-layer": "Medical place", + "filter": [ + "==", + "_symbol", + 11 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Medical place/Veterinarian_1" + ], + [ + 16.6, + "Medical place/Veterinarian" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Medical/Doctor", + "type": "symbol", + "source": "esri", + "source-layer": "Medical place", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Medical place/Doctor_1" + ], + [ + 16.6, + "Medical place/Doctor" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Medical/Dentist", + "type": "symbol", + "source": "esri", + "source-layer": "Medical place", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 17, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Medical place/Dentist_1" + ], + [ + 16.6, + "Medical place/Dentist" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Medical/Chiropractor", + "type": "symbol", + "source": "esri", + "source-layer": "Medical place", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Medical place/Chiropractor_1" + ], + [ + 16.6, + "Medical place/Chiropractor" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Medical/Therapist", + "type": "symbol", + "source": "esri", + "source-layer": "Medical place", + "filter": [ + "==", + "_symbol", + 10 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Medical place/Therapist_1" + ], + [ + 16.6, + "Medical place/Therapist" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Medical/Psychiatric Institute", + "type": "symbol", + "source": "esri", + "source-layer": "Medical place", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Medical place/Psychiatric Institute_1" + ], + [ + 16.6, + "Medical place/Psychiatric Institute" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Medical/Blood Bank", + "type": "symbol", + "source": "esri", + "source-layer": "Medical place", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Medical place/Blood Bank_1" + ], + [ + 16.6, + "Medical place/Blood Bank" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Other Sport", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 14 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Other Sport_1" + ], + [ + 16.6, + "Sport place/Other Sport" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Volleyball", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 26 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Volleyball_1" + ], + [ + 16.6, + "Sport place/Volleyball" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Tennis Court", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 25 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Tennis Court_1" + ], + [ + 16.6, + "Sport place/Tennis Court" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Swimming Pool", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 24 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Swimming Pool_1" + ], + [ + 16.6, + "Sport place/Swimming Pool" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Sports Field", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 22 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Sports Field_1" + ], + [ + 16.6, + "Sport place/Sports Field" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Soccer", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 20 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Soccer_1" + ], + [ + 16.6, + "Sport place/Soccer" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Rugby", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 19 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Rugby_1" + ], + [ + 16.6, + "Sport place/Rugby" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Roller Rink", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 18 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Roller Rink_1" + ], + [ + 16.6, + "Sport place/Roller Rink" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Racquetball Court", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 17 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Racquetball Court_1" + ], + [ + 16.6, + "Sport place/Racquetball Court" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Racetrack", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 16 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Racetrack_1" + ], + [ + 16.6, + "Sport place/Racetrack" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Racecourse", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 15 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Racecourse_1" + ], + [ + 16.6, + "Sport place/Racecourse" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Mini Golf", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 13 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Mini Golf_1" + ], + [ + 16.6, + "Sport place/Mini Golf" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Indoor Sports", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 12 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Indoor Sports_1" + ], + [ + 16.6, + "Sport place/Indoor Sports" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Ice Skating Rink", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 11 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Ice Skating Rink_1" + ], + [ + 16.6, + "Sport place/Ice Skating Rink" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Hockey", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 10 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Hockey_1" + ], + [ + 16.6, + "Sport place/Hockey" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Golf Driving Range", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Golf Driving Range_1" + ], + [ + 16.6, + "Sport place/Golf Driving Range" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Football", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Football_1" + ], + [ + 16.6, + "Sport place/Football" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Diving Center", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Diving Center_1" + ], + [ + 16.6, + "Sport place/Diving Center" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Disc Golf", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Disc Golf_1" + ], + [ + 16.6, + "Sport place/Disc Golf" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Curling", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Curling_1" + ], + [ + 16.6, + "Sport place/Curling" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Bowling Alley", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Bowling Alley_1" + ], + [ + 16.6, + "Sport place/Bowling Alley" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Billiards", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Billiards_1" + ], + [ + 16.6, + "Sport place/Billiards" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Baseball", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Baseball_1" + ], + [ + 16.6, + "Sport place/Baseball" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Badminton", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Badminton_1" + ], + [ + 16.6, + "Sport place/Badminton" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Other Outdoors", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Other Outdoors_1" + ], + [ + 16.6, + "Outdoors place/Other Outdoors" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Holiday Park", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Holiday Park_1" + ], + [ + 16.6, + "Outdoors place/Holiday Park" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Shooting Range", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 16 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Shooting Range_1" + ], + [ + 16.6, + "Outdoors place/Shooting Range" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Snowmobile", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 20 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Snowmobile_1" + ], + [ + 16.6, + "Outdoors place/Snowmobile" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Ski Lift", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 18 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Ski Lift_1" + ], + [ + 16.6, + "Outdoors place/Ski Lift" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Ski Resort", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 19 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Ski Resort_1" + ], + [ + 16.6, + "Outdoors place/Ski Resort" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Bike Park", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Bike Park_1" + ], + [ + 16.6, + "Outdoors place/Bike Park" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Skateboard Park", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 17 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Skateboard Park_1" + ], + [ + 16.6, + "Outdoors place/Skateboard Park" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Outdoor Service", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 10 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Outdoor Service_1" + ], + [ + 16.6, + "Outdoors place/Outdoor Service" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Fishing", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Fishing_1" + ], + [ + 16.6, + "Outdoors place/Fishing" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/RV Park", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 14 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/RV Park_1" + ], + [ + 16.6, + "Outdoors place/RV Park" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Trail", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 22 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Trail_1" + ], + [ + 16.6, + "Outdoors place/Trail" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Trailhead", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 23 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Trailhead_1" + ], + [ + 16.6, + "Outdoors place/Trailhead" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Shelter", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 15 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Shelter_1" + ], + [ + 16.6, + "Outdoors place/Shelter" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Campground", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Campground_1" + ], + [ + 16.6, + "Outdoors place/Campground" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Ranger Station", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 13 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Ranger Station_1" + ], + [ + 16.6, + "Outdoors place/Ranger Station" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Playground", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 12 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Playground_1" + ], + [ + 16.6, + "Outdoors place/Playground" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Other Food and Drink", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 71 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Other Food and Drink_1" + ], + [ + 16.6, + "Food and Drink place/Other Food and Drink" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Winery", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 103 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Winery_1" + ], + [ + 16.6, + "Food and Drink place/Winery" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Vietnamese Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 102 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Vietnamese Food_1" + ], + [ + 16.6, + "Food and Drink place/Vietnamese Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Vegetarian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 101 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Vegetarian Food_1" + ], + [ + 16.6, + "Food and Drink place/Vegetarian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Vegan Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 100 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Vegan Food_1" + ], + [ + 16.6, + "Food and Drink place/Vegan Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Turkish Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 99 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Turkish Food_1" + ], + [ + 16.6, + "Food and Drink place/Turkish Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Truck or Cart", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 98 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Truck or Cart_1" + ], + [ + 16.6, + "Food and Drink place/Truck or Cart" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Thai Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 97 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Thai Food_1" + ], + [ + 16.6, + "Food and Drink place/Thai Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Tea House", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 96 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Tea House_1" + ], + [ + 16.6, + "Food and Drink place/Tea House" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Tapas", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 95 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Tapas_1" + ], + [ + 16.6, + "Food and Drink place/Tapas" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Take Out and Delivery Only", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 94 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Take Out and Delivery Only_1" + ], + [ + 16.6, + "Food and Drink place/Take Out and Delivery Only" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Swiss Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 93 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Swiss Food_1" + ], + [ + 16.6, + "Food and Drink place/Swiss Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Sweet Shop", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 92 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Sweet Shop_1" + ], + [ + 16.6, + "Food and Drink place/Sweet Shop" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Sushi", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 91 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Sushi_1" + ], + [ + 16.6, + "Food and Drink place/Sushi" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Steak House", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 90 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Steak House_1" + ], + [ + 16.6, + "Food and Drink place/Steak House" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Spanish Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 89 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Spanish Food_1" + ], + [ + 16.6, + "Food and Drink place/Spanish Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Southwestern Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 88 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Southwestern Food_1" + ], + [ + 16.6, + "Food and Drink place/Southwestern Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Southeast Asian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 87 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Southeast Asian Food_1" + ], + [ + 16.6, + "Food and Drink place/Southeast Asian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/South American Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 86 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/South American Food_1" + ], + [ + 16.6, + "Food and Drink place/South American Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Soup", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 85 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Soup_1" + ], + [ + 16.6, + "Food and Drink place/Soup" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Snacks", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 84 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Snacks_1" + ], + [ + 16.6, + "Food and Drink place/Snacks" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Singaporean Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 83 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Singaporean Food_1" + ], + [ + 16.6, + "Food and Drink place/Singaporean Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Seafood", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 82 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Seafood_1" + ], + [ + 16.6, + "Food and Drink place/Seafood" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Scandinavian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 81 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Scandinavian Food_1" + ], + [ + 16.6, + "Food and Drink place/Scandinavian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Sandwich Shop", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 80 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Sandwich Shop_1" + ], + [ + 16.6, + "Food and Drink place/Sandwich Shop" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Russian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 79 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Russian Food_1" + ], + [ + 16.6, + "Food and Drink place/Russian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Portuguese Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 77 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Portuguese Food_1" + ], + [ + 16.6, + "Food and Drink place/Portuguese Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Polish Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 76 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Polish Food_1" + ], + [ + 16.6, + "Food and Drink place/Polish Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Peruvian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 74 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Peruvian Food_1" + ], + [ + 16.6, + "Food and Drink place/Peruvian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Pastries", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 73 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Pastries_1" + ], + [ + 16.6, + "Food and Drink place/Pastries" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Pakistani Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 72 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Pakistani Food_1" + ], + [ + 16.6, + "Food and Drink place/Pakistani Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Noodles", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 70 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Noodles_1" + ], + [ + 16.6, + "Food and Drink place/Noodles" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Moroccan Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 69 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Moroccan Food_1" + ], + [ + 16.6, + "Food and Drink place/Moroccan Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Middle Eastern Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 68 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Middle Eastern Food_1" + ], + [ + 16.6, + "Food and Drink place/Middle Eastern Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Mexican Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 67 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Mexican Food_1" + ], + [ + 16.6, + "Food and Drink place/Mexican Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Mediterranean Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 66 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Mediterranean Food_1" + ], + [ + 16.6, + "Food and Drink place/Mediterranean Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Malaysian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 65 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Malaysian Food_1" + ], + [ + 16.6, + "Food and Drink place/Malaysian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Lebanese Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 64 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Lebanese Food_1" + ], + [ + 16.6, + "Food and Drink place/Lebanese Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Latin American Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 63 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Latin American Food_1" + ], + [ + 16.6, + "Food and Drink place/Latin American Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Kosher Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 62 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Kosher Food_1" + ], + [ + 16.6, + "Food and Drink place/Kosher Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Korean Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 61 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Korean Food_1" + ], + [ + 16.6, + "Food and Drink place/Korean Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Japanese Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 60 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Japanese Food_1" + ], + [ + 16.6, + "Food and Drink place/Japanese Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Italian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 59 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Italian Food_1" + ], + [ + 16.6, + "Food and Drink place/Italian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Irish Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 58 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Irish Food_1" + ], + [ + 16.6, + "Food and Drink place/Irish Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/International Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 57 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/International Food_1" + ], + [ + 16.6, + "Food and Drink place/International Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Indonesian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 56 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Indonesian Food_1" + ], + [ + 16.6, + "Food and Drink place/Indonesian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Indian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 55 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Indian Food_1" + ], + [ + 16.6, + "Food and Drink place/Indian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Ice Cream Shop", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 54 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Ice Cream Shop_1" + ], + [ + 16.6, + "Food and Drink place/Ice Cream Shop" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Hungarian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 53 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Hungarian Food_1" + ], + [ + 16.6, + "Food and Drink place/Hungarian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Hot Dogs", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 52 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Hot Dogs_1" + ], + [ + 16.6, + "Food and Drink place/Hot Dogs" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Healthy Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 51 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Healthy Food_1" + ], + [ + 16.6, + "Food and Drink place/Healthy Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Hawaiian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 50 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Hawaiian Food_1" + ], + [ + 16.6, + "Food and Drink place/Hawaiian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Grill", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 49 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Grill_1" + ], + [ + 16.6, + "Food and Drink place/Grill" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Greek Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 48 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Greek Food_1" + ], + [ + 16.6, + "Food and Drink place/Greek Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/German Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 47 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/German Food_1" + ], + [ + 16.6, + "Food and Drink place/German Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Fusion Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 46 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Fusion Food_1" + ], + [ + 16.6, + "Food and Drink place/Fusion Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/French Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 45 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/French Food_1" + ], + [ + 16.6, + "Food and Drink place/French Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Fondue", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 44 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Fondue_1" + ], + [ + 16.6, + "Food and Drink place/Fondue" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Fine Dining", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 43 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Fine Dining_1" + ], + [ + 16.6, + "Food and Drink place/Fine Dining" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Filipino Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 42 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Filipino Food_1" + ], + [ + 16.6, + "Food and Drink place/Filipino Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/European Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 40 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/European Food_1" + ], + [ + 16.6, + "Food and Drink place/European Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Ethiopian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 39 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Ethiopian Food_1" + ], + [ + 16.6, + "Food and Drink place/Ethiopian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/East European Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 38 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/East European Food_1" + ], + [ + 16.6, + "Food and Drink place/East European Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Deli", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 37 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Deli_1" + ], + [ + 16.6, + "Food and Drink place/Deli" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Dairy Goods", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 36 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Dairy Goods_1" + ], + [ + 16.6, + "Food and Drink place/Dairy Goods" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Cuban Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 35 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Cuban Food_1" + ], + [ + 16.6, + "Food and Drink place/Cuban Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Creperie", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 34 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Creperie_1" + ], + [ + 16.6, + "Food and Drink place/Creperie" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Continental Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 33 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Continental Food_1" + ], + [ + 16.6, + "Food and Drink place/Continental Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Cocktail Lounge", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 31 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Cocktail Lounge_1" + ], + [ + 16.6, + "Food and Drink place/Cocktail Lounge" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Chinese Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 30 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Chinese Food_1" + ], + [ + 16.6, + "Food and Drink place/Chinese Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Chilean Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 29 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Chilean Food_1" + ], + [ + 16.6, + "Food and Drink place/Chilean Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Chicken Restaurant", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 28 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Chicken Restaurant_1" + ], + [ + 16.6, + "Food and Drink place/Chicken Restaurant" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Caucasian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 27 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Caucasian Food_1" + ], + [ + 16.6, + "Food and Drink place/Caucasian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Catering", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 26 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Catering_1" + ], + [ + 16.6, + "Food and Drink place/Catering" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Casual Dining", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 25 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Casual Dining_1" + ], + [ + 16.6, + "Food and Drink place/Casual Dining" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Caribbean Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 24 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Caribbean Food_1" + ], + [ + 16.6, + "Food and Drink place/Caribbean Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Cambodian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 23 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Cambodian Food_1" + ], + [ + 16.6, + "Food and Drink place/Cambodian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Californian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 22 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Californian Food_1" + ], + [ + 16.6, + "Food and Drink place/Californian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Cajun and Creole Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 21 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Cajun and Creole Food_1" + ], + [ + 16.6, + "Food and Drink place/Cajun and Creole Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Cafeteria", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 20 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Cafeteria_1" + ], + [ + 16.6, + "Food and Drink place/Cafeteria" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Burmese Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 19 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Burmese Food_1" + ], + [ + 16.6, + "Food and Drink place/Burnmese Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Burgers", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 18 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Burgers_1" + ], + [ + 16.6, + "Food and Drink place/Burgers" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/British Isles Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 17 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/British Isles Food_1" + ], + [ + 16.6, + "Food and Drink place/British Isles Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Brewpub", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 16 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Brewpub_1" + ], + [ + 16.6, + "Food and Drink place/Brewpub" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Breakfast", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 14 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Breakfast_1" + ], + [ + 16.6, + "Food and Drink place/Breakfast" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Brazilian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 13 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Brazilian Food_1" + ], + [ + 16.6, + "Food and Drink place/Brazilian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Bohemian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 12 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Bohemian Food_1" + ], + [ + 16.6, + "Food and Drink place/Bohemian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Bistro", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 11 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Bistro_1" + ], + [ + 16.6, + "Food and Drink place/Bistro" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Belgian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 10 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Belgian Food_1" + ], + [ + 16.6, + "Food and Drink place/Belgian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/BBQ and Southern Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/BBQ and Southern Food_1" + ], + [ + 16.6, + "Food and Drink place/BBQ and Southern Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Baltic Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Baltic Food_1" + ], + [ + 16.6, + "Food and Drink place/Baltic Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Bakery", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Bakery_1" + ], + [ + 16.6, + "Food and Drink place/Bakery" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Austrian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Austrian Food_1" + ], + [ + 16.6, + "Food and Drink place/Austrian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Australian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Australian Food_1" + ], + [ + 16.6, + "Food and Drink place/Australian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Asian Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Asian Food_1" + ], + [ + 16.6, + "Food and Drink place/Asian Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Argentinean Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Argentinean Food_1" + ], + [ + 16.6, + "Food and Drink place/Argentinean Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/American Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/American Food_1" + ], + [ + 16.6, + "Food and Drink place/American Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/African Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/African Food_1" + ], + [ + 16.6, + "Food and Drink place/African Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Other Arts and Entertainment", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 33 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Other Arts and Entertainment_1" + ], + [ + 16.6, + "Arts and Entertainment place/Other Arts and Entertainment" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Entertainment (Adult)", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 17 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Entertainment (Adult)_1" + ], + [ + 16.6, + "Arts and Entertainment place/Entertainment (Adult)" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Named Place", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 29 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Named Place_1" + ], + [ + 16.6, + "Arts and Entertainment place/Named Place" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Video Arcade", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 44 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Video Arcade_1" + ], + [ + 16.6, + "Arts and Entertainment place/Video Arcade" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Arcade", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Arcade_1" + ], + [ + 16.6, + "Arts and Entertainment place/Arcade" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Laser Tag", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 26 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Laser Tag_1" + ], + [ + 16.6, + "Arts and Entertainment place/Laser Tag" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Go Kart Track", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 22 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Go Kart Track_1" + ], + [ + 16.6, + "Arts and Entertainment place/Go Kart Track" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Fair", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 20 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Fair_1" + ], + [ + 16.6, + "Arts and Entertainment place/Fair" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Circus", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 11 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Circus_1" + ], + [ + 16.6, + "Arts and Entertainment place/Circus" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Tour Provider", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 41 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Tour Provider_1" + ], + [ + 16.6, + "Arts and Entertainment place/Tour Provider" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Tourist Information", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 43 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Tourist Information_1" + ], + [ + 16.6, + "Arts and Entertainment place/Tourist Information" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Banquet Hall", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Banquet Hall_1" + ], + [ + 16.6, + "Arts and Entertainment place/Banquet Hall" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Pool Hall", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 35 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Pool Hall_1" + ], + [ + 16.6, + "Arts and Entertainment place/Pool Hall" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Ballroom", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Ballroom_1" + ], + [ + 16.6, + "Arts and Entertainment place/Ballroom" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Event Space", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 18 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Event Space_1" + ], + [ + 16.6, + "Arts and Entertainment place/Event Space" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Club House", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 12 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Club House_1" + ], + [ + 16.6, + "Arts and Entertainment place/Club House" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Social Club", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 40 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Social Club_1" + ], + [ + 16.6, + "Arts and Entertainment place/Social Club" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Comedy Club", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 13 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Comedy Club_1" + ], + [ + 16.6, + "Arts and Entertainment place/Comedy Club" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Country Dance Club", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 14 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Country Dance Club_1" + ], + [ + 16.6, + "Arts and Entertainment place/Country Dance Club" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Dancing", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 16 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Dancing_1" + ], + [ + 16.6, + "Arts and Entertainment place/Dancing" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Karaoke", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 25 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Karaoke_1" + ], + [ + 16.6, + "Arts and Entertainment place/Karaoke" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Live Music", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 27 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Live Music_1" + ], + [ + 16.6, + "Arts and Entertainment place/Live Music" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Jazz Club", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 24 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Jazz Club_1" + ], + [ + 16.6, + "Arts and Entertainment place/Jazz Club" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Nightlife", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 31 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Nightlife_1" + ], + [ + 16.6, + "Arts and Entertainment place/Nightlife" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/General Entertainment", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 21 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/General Entertainment_1" + ], + [ + 16.6, + "Arts and Entertainment place/General Entertainment" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Recreation Facility", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 38 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Recreation Facility_1" + ], + [ + 16.6, + "Arts and Entertainment place/Recreation Facility" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Recreation Center", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 37 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Recreation Center_1" + ], + [ + 16.6, + "Arts and Entertainment place/Recreation Center" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Cultural Center", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 15 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Cultural Center_1" + ], + [ + 16.6, + "Arts and Entertainment place/Cultural Center" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Auditorium", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Auditorium_1" + ], + [ + 16.6, + "Arts and Entertainment place/Auditorium" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Public Art", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 36 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Public Art_1" + ], + [ + 16.6, + "Arts and Entertainment place/Public Art" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Exhibit", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 19 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Exhibit_1" + ], + [ + 16.6, + "Arts and Entertainment place/Exhibit" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Art Gallery", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Art Gallery_1" + ], + [ + 16.6, + "Arts and Entertainment place/Art Gallery" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Other Public Facility", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 14 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Other Public Facility_1" + ], + [ + 16.6, + "Government or Public Facility place/Other Public Facility" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Other Government", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 13 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Other Government_1" + ], + [ + 16.6, + "Government or Public Facility place/Other Government" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Voting Booth", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 22 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Voting Booth_1" + ], + [ + 16.6, + "Government or Public Facility place/Voting Booth" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Animal Shelter", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Animal Shelter_1" + ], + [ + 16.6, + "Government or Public Facility place/Animal Shelter" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Public Restroom", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 20 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Public Restroom_1" + ], + [ + 16.6, + "Government or Public Facility place/Public Restroom" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Organization", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 12 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Organization_1" + ], + [ + 16.6, + "Government or Public Facility place/Organization" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Civic Center", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Civic Center_1" + ], + [ + 16.6, + "Government or Public Facility place/Civic Center" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/County Council", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/County Council_1" + ], + [ + 16.6, + "Government or Public Facility place/County Council" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Public Administration", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 19 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Public Administration_1" + ], + [ + 16.6, + "Government or Public Facility place/Public Administration" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Library", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 10 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Library_1" + ], + [ + 16.6, + "Government or Public Facility place/Library" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Social Service", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 21 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Social Service_1" + ], + [ + 16.6, + "Government or Public Facility place/Social Service" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Prison", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 18 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Prison_1" + ], + [ + 16.6, + "Government or Public Facility place/Prison" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Embassy", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Embassy_1" + ], + [ + 16.6, + "Government or Public Facility place/Embassy" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Police Service", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 15 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Police Service_1" + ], + [ + 16.6, + "Government or Public Facility place/Police Service" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Other Service", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 101 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Other Service_1" + ], + [ + 16.6, + "Shop or Service place/Other Service" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Other Shop", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 102 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Other Shop_1" + ], + [ + 16.6, + "Shop or Service place/Other Shop" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Premium Default", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 111 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Premium Default_1" + ], + [ + 16.6, + "Shop or Service place/Premium Default" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Modeling Agencies", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 89 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Modeling Agencies_1" + ], + [ + 16.6, + "Shop or Service place/Modeling Agencies" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Marriage and Match Making Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 84 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Marriage and Match Making Services_1" + ], + [ + 16.6, + "Shop or Service place/Marriage and Match Making Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Wedding Services and Bridal Studio", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 151 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Wedding Services and Bridal Studio_1" + ], + [ + 16.6, + "Shop or Service place/Wedding Services and Bridal Studio" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Bridal Shop", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 21 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Bridal Shop_1" + ], + [ + 16.6, + "Shop or Service place/Bridal Shop" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Florist", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 55 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Florist_1" + ], + [ + 16.6, + "Shop or Service place/Florist" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Flowers and Jewelry", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 56 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Flowers and Jewelry_1" + ], + [ + 16.6, + "Shop or Service place/Flowers and Jewelry" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Jeweler", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 75 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Jeweler_1" + ], + [ + 16.6, + "Shop or Service place/Jeweler" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Ambulance Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Ambulance Services_1" + ], + [ + 16.6, + "Shop or Service place/Ambulance Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Medical Service", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 85 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Medical Service_1" + ], + [ + 16.6, + "Shop or Service place/Medical Service" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Healthcare", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 68 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Healthcare_1" + ], + [ + 16.6, + "Shop or Service place/Healthcare" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Crematorium", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 40 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Crematorium_1" + ], + [ + 16.6, + "Shop or Service place/Crematorium" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Funeral Homes and Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 60 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Funeral Homes and Services_1" + ], + [ + 16.6, + "Shop or Service place/Funeral Homes and Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Property Management", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 113 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Property Management_1" + ], + [ + 16.6, + "Shop or Service place/Property Management" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Realtor", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 114 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Realtor_1" + ], + [ + 16.6, + "Shop or Service place/Realtor" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Registration Office", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 116 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Registration Office_1" + ], + [ + 16.6, + "Shop or Service place/Registration Office" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Engineering and Scientific Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 46 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Engineering and Scientific Services_1" + ], + [ + 16.6, + "Shop or Service place/Engineering and Scientific Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Specialty Trade Contractors", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 129 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Specialty Trade Contractors_1" + ], + [ + 16.6, + "Shop or Service place/Specialty Trade Contractors" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/B2B Restaurant Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 10 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/B2B Restaurant Services_1" + ], + [ + 16.6, + "Shop or Service place/B2B Restaurant Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/B2B Sales and Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 11 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/B2B Sales and Services_1" + ], + [ + 16.6, + "Shop or Service place/B2B Sales and Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Business Service", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 23 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Business Service_1" + ], + [ + 16.6, + "Shop or Service place/Business Service" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Printing and Publishing", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 112 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Printing and Publishing_1" + ], + [ + 16.6, + "Shop or Service place/Printing and Publishing" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Commercial Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 32 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Commercial Services_1" + ], + [ + 16.6, + "Shop or Service place/Commercial Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Consumer Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 36 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Consumer Services_1" + ], + [ + 16.6, + "Shop or Service place/Consumer Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Consumer Goods", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 35 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Consumer Goods_1" + ], + [ + 16.6, + "Shop or Service place/Consumer Goods" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Power Equipment Dealer", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 110 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Power Equipment Dealer_1" + ], + [ + 16.6, + "Shop or Service place/Power Equipment Dealer" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Major Appliance", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 82 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Major Appliance_1" + ], + [ + 16.6, + "Shop or Service place/Major Appliance" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Office Equipment Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 98 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Office Equipment Services_1" + ], + [ + 16.6, + "Shop or Service place/Office Equipment Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Non-Store Retailers", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 96 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Non-Store Retailers_1" + ], + [ + 16.6, + "Shop or Service place/Non-Store Retailers" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Travel Agent", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 142 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Travel Agent_1" + ], + [ + 16.6, + "Shop or Service place/Travel Agent" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Translation Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 140 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Translation Services_1" + ], + [ + 16.6, + "Shop or Service place/Translation Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Recruiting Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 115 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Recruiting Services_1" + ], + [ + 16.6, + "Shop or Service place/Recruiting Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Consulting Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 33 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Consulting Services_1" + ], + [ + 16.6, + "Shop or Service place/Consulting Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Investigation Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 73 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Investigation Services_1" + ], + [ + 16.6, + "Shop or Service place/Investigation Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Legal Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 77 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Legal Services_1" + ], + [ + 16.6, + "Shop or Service place/Legal Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Attorney", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Attorney_1" + ], + [ + 16.6, + "Shop or Service place/Attorney" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Notary", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 97 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Notary_1" + ], + [ + 16.6, + "Shop or Service place/Notary" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Second Hand Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 122 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Second Hand Store_1" + ], + [ + 16.6, + "Shop or Service place/Second Hand Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Pawnshop", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 104 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Pawnshop_1" + ], + [ + 16.6, + "Shop or Service place/Pawnshop" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Antique Shop", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Antique Shop_1" + ], + [ + 16.6, + "Shop or Service place/Antique Shop" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Flea Market", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 53 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Flea Market_1" + ], + [ + 16.6, + "Shop or Service place/Flea Market" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Hobby Shop", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 69 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Hobby Shop_1" + ], + [ + 16.6, + "Shop or Service place/Hobby Shop" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Musical Instrument and Supplies", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 94 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Musical Instrument and Supplies_1" + ], + [ + 16.6, + "Shop or Service place/Musical Instrument and Supplies" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Arts and Crafts Supplies", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Arts and Crafts Supplies_1" + ], + [ + 16.6, + "Shop or Service place/Arts and Crafts Supplies" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Child Care Service (Day care)", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 29 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Child Care Service (Day care)_1" + ], + [ + 16.6, + "Shop or Service place/Child Care Service (Day care)" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Childrens Apparel", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 30 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Childrens Apparel_1" + ], + [ + 16.6, + "Shop or Service place/Childrens Apparel" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Baby Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 12 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Baby Store_1" + ], + [ + 16.6, + "Shop or Service place/Baby Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Candy Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 25 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Candy Store_1" + ], + [ + 16.6, + "Shop or Service place/Candy Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Toy Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 139 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Toy Store_1" + ], + [ + 16.6, + "Shop or Service place/Toy Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Transportation Service", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 141 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Transportation Service_1" + ], + [ + 16.6, + "Shop or Service place/Transportation Service" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Cargo Transportation", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 27 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Cargo Transportation_1" + ], + [ + 16.6, + "Shop or Service place/Cargo Transportation" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Aviation", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Aviation_1" + ], + [ + 16.6, + "Shop or Service place/Aviation" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Truck Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 145 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Truck Services_1" + ], + [ + 16.6, + "Shop or Service place/Truck Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Truck Dealership", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 143 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Truck Dealership_1" + ], + [ + 16.6, + "Shop or Service place/Truck Dealership" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Truck Wash", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 146 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Truck Wash_1" + ], + [ + 16.6, + "Shop or Service place/Truck Wash" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Truck Repair", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 144 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Truck Repair_1" + ], + [ + 16.6, + "Shop or Service place/Truck Repair" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Motorcycle Shop", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 92 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Motorcycle Shop_1" + ], + [ + 16.6, + "Shop or Service place/Motorcycle Shop" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Motorcycle Service", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 91 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Motorcycle Service_1" + ], + [ + 16.6, + "Shop or Service place/Motorcycle Service" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Bicycle Shop", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 16 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Bicycle Shop_1" + ], + [ + 16.6, + "Shop or Service place/Bicycle Shop" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Bicycle Service", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 15 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Bicycle Service_1" + ], + [ + 16.6, + "Shop or Service place/Bicycle Service" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Rental RV", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 119 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Rental RV_1" + ], + [ + 16.6, + "Shop or Service place/Rental RV" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Rental Cars", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 118 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Rental Cars_1" + ], + [ + 16.6, + "Shop or Service place/Rental Cars" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Automobile Club", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Automobile Club_1" + ], + [ + 16.6, + "Shop or Service place/Automobile Club" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Towing Service", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 138 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Towing Service_1" + ], + [ + 16.6, + "Shop or Service place/Towing Service" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Road Assistance", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 121 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Road Assistance_1" + ], + [ + 16.6, + "Shop or Service place/Road Assistance" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/EV Charging Station", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 48 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/EV Charging Station_1" + ], + [ + 16.6, + "Shop or Service place/EV Charging Station" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Emission Testing", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 45 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Emission Testing_1" + ], + [ + 16.6, + "Shop or Service place/Emission Testing" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Auto Maintenance", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Auto Maintenance_1" + ], + [ + 16.6, + "Shop or Service place/Auto Maintenance" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Auto Parts", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Auto Parts_1" + ], + [ + 16.6, + "Shop or Service place/Auto Parts" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Tire Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 136 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Tire Store_1" + ], + [ + 16.6, + "Shop or Service place/Tire Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Car Wash", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 26 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Car Wash_1" + ], + [ + 16.6, + "Shop or Service place/Car Wash" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Used Car Dealership", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 147 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Used Car Dealership_1" + ], + [ + 16.6, + "Shop or Service place/Used Car Dealership" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Auto Dealership", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Auto Dealership_1" + ], + [ + 16.6, + "Shop or Service place/Auto Dealership" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Bill Payment Service", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 18 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Bill Payment Service_1" + ], + [ + 16.6, + "Shop or Service place/Bill Payment Service" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Check Cashing Service", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 28 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Check Cashing Service_1" + ], + [ + 16.6, + "Shop or Service place/Check Cashing Service" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Money Transferring Service", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 90 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Money Transferring Service_1" + ], + [ + 16.6, + "Shop or Service place/Money Transferring Service" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Finance and Insurance", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 50 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Finance and Insurance_1" + ], + [ + 16.6, + "Shop or Service place/Finance and Insurance" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Financial Investment Firm", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 51 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Financial Investment Firm_1" + ], + [ + 16.6, + "Shop or Service place/Financial Investment Firm" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Tax Service", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 134 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Tax Service_1" + ], + [ + 16.6, + "Shop or Service place/Tax Service" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Lumber", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 80 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Lumber_1" + ], + [ + 16.6, + "Shop or Service place/Lumber" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Exterminating and Pest Control", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 49 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Exterminating and Pest Control_1" + ], + [ + 16.6, + "Shop or Service place/Exterminating and Pest Control" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Dry Cleaning and Laundry", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 43 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Dry Cleaning and Laundry_1" + ], + [ + 16.6, + "Shop or Service place/Dry Cleaning and Laundry" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Floor and Carpet", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 54 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Floor and Carpet_1" + ], + [ + 16.6, + "Shop or Service place/Floor and Carpet" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Glass and Window", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 65 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Glass and Window_1" + ], + [ + 16.6, + "Shop or Service place/Glass and Window" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Interior and Exterior Design", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 71 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Interior and Exterior Design_1" + ], + [ + 16.6, + "Shop or Service place/Interior and Exterior Design" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Paint Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 103 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Paint Store_1" + ], + [ + 16.6, + "Shop or Service place/Paint Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Janitorial Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 74 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Janitorial Services_1" + ], + [ + 16.6, + "Shop or Service place/Janitorial Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Maid Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 81 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Maid Services_1" + ], + [ + 16.6, + "Shop or Service place/Maid Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Landscaping Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 76 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Landscaping Services_1" + ], + [ + 16.6, + "Shop or Service place/Landscaping Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Garden Center", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 62 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Garden Center_1" + ], + [ + 16.6, + "Shop or Service place/Garden Center" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Repair Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 120 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Repair Services_1" + ], + [ + 16.6, + "Shop or Service place/Repair Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Plumbing", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 109 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Plumbing_1" + ], + [ + 16.6, + "Shop or Service place/Plumbing" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Electrical", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 44 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Electrical_1" + ], + [ + 16.6, + "Shop or Service place/Electrical" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Locksmiths and Security Systems Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 79 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Locksmiths and Security Systems Services_1" + ], + [ + 16.6, + "Shop or Service place/Locksmiths and Security Systems Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Wholesale Warehouse", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 153 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Wholesale Warehouse_1" + ], + [ + 16.6, + "Shop or Service place/Wholesale Warehouse" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Furniture Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 61 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Furniture Store_1" + ], + [ + 16.6, + "Shop or Service place/Furniture Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Home Improvement Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 70 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Home Improvement Store_1" + ], + [ + 16.6, + "Shop or Service place/Home Improvement Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Couriers", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 38 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Couriers_1" + ], + [ + 16.6, + "Shop or Service place/Couriers" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Big Box Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 17 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Big Box Store_1" + ], + [ + 16.6, + "Shop or Service place/Big Box Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Mover", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 93 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Mover_1" + ], + [ + 16.6, + "Shop or Service place/Mover" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Storage", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 131 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Storage_1" + ], + [ + 16.6, + "Shop or Service place/Storage" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Rental and Leasing", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 117 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Rental and Leasing_1" + ], + [ + 16.6, + "Shop or Service place/Rental and Leasing" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Tailor", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 132 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Tailor_1" + ], + [ + 16.6, + "Shop or Service place/Tailor" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Sewing, Needlework and Piece Goods", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 123 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Sewing, Needlework and Piece Goods_1" + ], + [ + 16.6, + "Shop or Service place/Sewing, Needlework and Piece Goods" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Body Piercing and Tattoos", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 19 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Body Piercing and Tattoos_1" + ], + [ + 16.6, + "Shop or Service place/Body Piercing and Tattoos" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Tanning Salon", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 133 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Tanning Salon_1" + ], + [ + 16.6, + "Shop or Service place/Tanning Salon" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Barber", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 14 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Barber_1" + ], + [ + 16.6, + "Shop or Service place/Barber" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Hair and Beauty", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 67 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Hair and Beauty_1" + ], + [ + 16.6, + "Shop or Service place/Hair and Beauty" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Nail Salon", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 95 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Nail Salon_1" + ], + [ + 16.6, + "Shop or Service place/Nail Salon" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Spa", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 125 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Spa_1" + ], + [ + 16.6, + "Shop or Service place/Spa" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Wellness Center and Services", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 152 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Wellness Center and Services_1" + ], + [ + 16.6, + "Shop or Service place/Wellness Center and Services" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Optical", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 100 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Optical_1" + ], + [ + 16.6, + "Shop or Service place/Optical" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Butcher", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 24 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Butcher_1" + ], + [ + 16.6, + "Shop or Service place/Butcher" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Specialty Food Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 127 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Specialty Food Store_1" + ], + [ + 16.6, + "Shop or Service place/Specialty Food Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Food and Beverage Shop", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 57 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Food and Beverage Shop_1" + ], + [ + 16.6, + "Shop or Service place/Food and Beverage Shop" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Liquor Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 78 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Liquor Store_1" + ], + [ + 16.6, + "Shop or Service place/Liquor Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Office Supplies Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 99 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Office Supplies Store_1" + ], + [ + 16.6, + "Shop or Service place/Office Supplies Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Sporting Goods Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 130 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Sporting Goods Store_1" + ], + [ + 16.6, + "Shop or Service place/Sporting Goods Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Pet Care", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 105 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Pet Care_1" + ], + [ + 16.6, + "Shop or Service place/Pet Care" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Pet Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 106 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Pet Store_1" + ], + [ + 16.6, + "Shop or Service place/Pet Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Bookstore", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 20 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Bookstore_1" + ], + [ + 16.6, + "Shop or Service place/Bookstore" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Tobacco", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 137 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Tobacco_1" + ], + [ + 16.6, + "Shop or Service place/Tobacco" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Specialty Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 128 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Specialty Store_1" + ], + [ + 16.6, + "Shop or Service place/Specialty Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Consumer Electronics Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 34 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Consumer Electronics Store_1" + ], + [ + 16.6, + "Shop or Service place/Consumer Electronics Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Entertainment Electronics", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 47 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Entertainment Electronics_1" + ], + [ + 16.6, + "Shop or Service place/Entertainment Electronics" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Video and Game Rental", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 149 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Video and Game Rental_1" + ], + [ + 16.6, + "Shop or Service place/Video and Game Rental" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Video Shop", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 150 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Video Shop_1" + ], + [ + 16.6, + "Shop or Service place/Video Shop" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Photography", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 108 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Photography_1" + ], + [ + 16.6, + "Shop or Service place/Photography" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Telephone Service", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 135 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Telephone Service_1" + ], + [ + 16.6, + "Shop or Service place/Telephone Service" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Mobile Service Center", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 88 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Mobile Service Center_1" + ], + [ + 16.6, + "Shop or Service place/Mobile Service Center" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Mobile Phone Shop", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 87 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Mobile Phone Shop_1" + ], + [ + 16.6, + "Shop or Service place/Mobile Phone Shop" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Specialty Clothing Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 126 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Specialty Clothing Store_1" + ], + [ + 16.6, + "Shop or Service place/Specialty Clothing Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Footwear", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 59 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Footwear_1" + ], + [ + 16.6, + "Shop or Service place/Footwear" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Mens Apparel", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 86 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Mens Apparel_1" + ], + [ + 16.6, + "Shop or Service place/Mens Apparel" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Womens Apparel", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 154 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Womens Apparel_1" + ], + [ + 16.6, + "Shop or Service place/Womens Apparel" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Clothing Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 31 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Clothing Store_1" + ], + [ + 16.6, + "Shop or Service place/Clothing Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Internet Cafe", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 72 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Internet Cafe_1" + ], + [ + 16.6, + "Shop or Service place/Internet Cafe" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Fitness Center", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 52 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Fitness Center_1" + ], + [ + 16.6, + "Shop or Service place/Fitness Center" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Convenience Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 37 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Convenience Store_1" + ], + [ + 16.6, + "Shop or Service place/Convenience Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/ATM", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/ATM_1" + ], + [ + 16.6, + "Shop or Service place/ATM" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Credit Union", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 39 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Credit Union_1" + ], + [ + 16.6, + "Shop or Service place/Credit Union" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Bank", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 13 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Bank_1" + ], + [ + 16.6, + "Shop or Service place/Bank" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Food Market", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 58 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Food Market_1" + ], + [ + 16.6, + "Shop or Service place/Food Market" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Business Facility", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 22 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Business Facility_1" + ], + [ + 16.6, + "Shop or Service place/Business Facility" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Discount Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 42 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Discount Store_1" + ], + [ + 16.6, + "Shop or Service place/Discount Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Variety Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 148 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Variety Store_1" + ], + [ + 16.6, + "Shop or Service place/Variety Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Road tunnel/label/Local", + "type": "symbol", + "source": "esri", + "source-layer": "Road tunnel/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 5 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 13, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.09, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 13, + 10 + ], + [ + 16, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Road tunnel/label/Minor", + "type": "symbol", + "source": "esri", + "source-layer": "Road tunnel/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 4 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-letter-spacing": 0.09, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Road tunnel/label/Major, alt name", + "type": "symbol", + "source": "esri", + "source-layer": "Road tunnel/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 3 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-letter-spacing": 0.09, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Road tunnel/label/Major", + "type": "symbol", + "source": "esri", + "source-layer": "Road tunnel/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 2 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-letter-spacing": 0.09, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Road tunnel/label/Freeway Motorway, alt name", + "type": "symbol", + "source": "esri", + "source-layer": "Road tunnel/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 1 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 11, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-letter-spacing": 0.09, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 11, + 10 + ], + [ + 16, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Road tunnel/label/Highway", + "type": "symbol", + "source": "esri", + "source-layer": "Road tunnel/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 7 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 11, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-letter-spacing": 0.09, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 11, + 10 + ], + [ + 16, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Road tunnel/label/Freeway Motorway", + "type": "symbol", + "source": "esri", + "source-layer": "Road tunnel/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 0 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 11, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-letter-spacing": 0.09, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 11, + 10 + ], + [ + 16, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Road/label/Rectangle hexagon brown white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 72 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle hexagon brown white (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#81695E" + } + }, + { + "id": "Road/label/Rectangle hexagon green white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 70 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle hexagon green white (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#718574" + } + }, + { + "id": "Road/label/Rectangle hexagon red white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 68 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle hexagon red white (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#B66D58" + } + }, + { + "id": "Road/label/Rectangle hexagon blue white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 66 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle hexagon blue white (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#5B708F" + } + }, + { + "id": "Road/label/Rectangle hexagon brown white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 71 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle hexagon brown white/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#81695E" + } + }, + { + "id": "Road/label/Rectangle hexagon green white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 69 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle hexagon green white/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#718574" + } + }, + { + "id": "Road/label/Rectangle hexagon red white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 67 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle hexagon red white/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#B66D58" + } + }, + { + "id": "Road/label/Rectangle hexagon blue white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 65 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle hexagon blue white/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#5B708F" + } + }, + { + "id": "Road/label/Octagon green white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 74 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.02, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Octagon green white (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FDFDFD" + } + }, + { + "id": "Road/label/Hexagon orange black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 64 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.03, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Hexagon orange black (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/Hexagon green white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 62 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.03, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Hexagon green white (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FDFDFD" + } + }, + { + "id": "Road/label/Hexagon red white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 60 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.03, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Hexagon red white (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/Hexagon white black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 56 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.03, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Hexagon white black (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/Pentagon green yellow (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 54 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Pentagon green yellow (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFF73" + } + }, + { + "id": "Road/label/Pentagon green white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 52 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Pentagon green white (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/Pentagon yellow black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 50 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.02, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Pentagon yellow black (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/Pentagon blue white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 48 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-letter-spacing": 0.02, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Pentagon blue white (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/Pentagon white black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 46 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Pentagon white black (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.3 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/Pentagon inverse white black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 44 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Pentagon inverse white black (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.3 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/Rectangle green yellow (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 42 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle green yellow (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.2 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFF73" + } + }, + { + "id": "Road/label/Rectangle green white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 40 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle green white (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.2 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/Rectangle yellow black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 38 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle yellow black (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.2 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/Hexagon blue white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 58 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.03, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Hexagon blue white (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport" + }, + "paint": { + "text-color": "#FDFDFD" + } + }, + { + "id": "Road/label/Rectangle red white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 36 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle red white (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.2 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/Rectangle blue white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 34 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle blue white (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.2 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/Rectangle white black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 32 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle white black (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.2 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/V-shaped white black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 30 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/V-shaped white black (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#343434" + } + }, + { + "id": "Road/label/U-shaped blue white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 28 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-letter-spacing": 0.02, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/U-shaped blue white (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/U-shaped red white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 26 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/U-shaped red white (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/U-shaped yellow black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 24 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-letter-spacing": 0.02, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/U-shaped yellow black (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/U-shaped green leaf (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 22 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/U-shaped green leaf (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#343434" + } + }, + { + "id": "Road/label/U-shaped white green (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 20 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/U-shaped white green (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/U-shaped white black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 18 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/U-shaped white black (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/Secondary Hwy red white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 16 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Secondary Hwy red white (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/Secondary Hwy green white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 14 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Secondary Hwy green white (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/Secondary Hwy white black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 12 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Secondary Hwy white black (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/Shield white black (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 10 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Shield white black (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#343434" + } + }, + { + "id": "Road/label/Shield blue white (Alt)", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 8 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Shield blue white (Alt)/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FDFDFD" + } + }, + { + "id": "Road/label/Octagon green white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 73 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.02, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Octagon green white/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FDFDFD" + } + }, + { + "id": "Road/label/Hexagon orange black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 63 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.03, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Hexagon orange black/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/Hexagon green white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 61 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.03, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Hexagon green white/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FDFDFD" + } + }, + { + "id": "Road/label/Hexagon red white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 59 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.03, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Hexagon red white/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/Hexagon white black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 55 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.03, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Hexagon white black/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/Pentagon green yellow", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 53 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Pentagon green yellow/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFF73" + } + }, + { + "id": "Road/label/Pentagon green white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 51 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Pentagon green white/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/Pentagon yellow black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 49 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.02, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Pentagon yellow black/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/Pentagon blue white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 47 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-letter-spacing": 0.02, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Pentagon blue white/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/Pentagon white black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 45 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Pentagon white black/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.3 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/Pentagon inverse white black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 43 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Pentagon inverse white black/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.3 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/Rectangle green yellow", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 41 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle green yellow/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.2 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFF73" + } + }, + { + "id": "Road/label/Rectangle green white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 39 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle green white/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.2 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/Rectangle yellow black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 37 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle yellow black/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.2 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/Hexagon blue white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 57 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.03, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Hexagon blue white/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport" + }, + "paint": { + "text-color": "#FDFDFD" + } + }, + { + "id": "Road/label/Rectangle red white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 35 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle red white/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.2 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/Rectangle blue white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 33 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle blue white/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.2 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/Rectangle white black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 31 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Rectangle white black/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.2 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/V-shaped white black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 29 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/V-shaped white black/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#343434" + } + }, + { + "id": "Road/label/U-shaped blue white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 27 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-letter-spacing": 0.02, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/U-shaped blue white/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/U-shaped red white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 25 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/U-shaped red white/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/U-shaped yellow black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 23 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-letter-spacing": 0.02, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/U-shaped yellow black/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/U-shaped green leaf", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 21 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/U-shaped green leaf/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#343434" + } + }, + { + "id": "Road/label/U-shaped white green", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 19 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/U-shaped white green/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/U-shaped white black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 17 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/U-shaped white black/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/Secondary Hwy red white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 15 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Secondary Hwy red white/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/Secondary Hwy green white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 13 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Secondary Hwy green white/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#FFFFFF" + } + }, + { + "id": "Road/label/Secondary Hwy white black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 11 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Secondary Hwy white black/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#000000" + } + }, + { + "id": "Road/label/Shield white black", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 9 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 9.33, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Shield white black/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport", + "text-padding": 30 + }, + "paint": { + "text-color": "#343434" + } + }, + { + "id": "Road/label/Shield blue white", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 7 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 10, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-size": 8.67, + "text-max-width": 8, + "text-field": "{_name}", + "icon-image": "Road/Shield blue white/{_len}", + "icon-rotation-alignment": "viewport", + "text-rotation-alignment": "viewport", + "text-padding": { + "stops": [ + [ + 6, + 30 + ], + [ + 9, + 20 + ], + [ + 12, + 15 + ] + ] + } + }, + "paint": { + "text-color": "#FDFDFD" + } + }, + { + "id": "Road/label/Local", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 5 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 13, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-letter-spacing": 0.09, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 13, + 10 + ], + [ + 16, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Road/label/Minor", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 4 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-letter-spacing": 0.09, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-padding": { + "stops": [ + [ + 10, + 20 + ], + [ + 18, + 2 + ] + ] + }, + "text-size": { + "base": 1.2, + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Road/label/Major, alt name", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 3 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-letter-spacing": 0.09, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Road/label/Major", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 2 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-letter-spacing": 0.09, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Road/label/Freeway Motorway, alt name", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 1 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 11, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-letter-spacing": 0.09, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 11, + 10 + ], + [ + 16, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Road/label/Highway", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 75 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 11, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-letter-spacing": 0.09, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 11, + 10 + ], + [ + 16, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Road/label/Freeway Motorway", + "type": "symbol", + "source": "esri", + "source-layer": "Road/label", + "filter": [ + "all", + [ + "==", + "_label_class", + 0 + ], + [ + "!in", + "Viz", + 3 + ] + ], + "minzoom": 11, + "layout": { + "symbol-placement": "line", + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-letter-spacing": 0.09, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 11, + 10 + ], + [ + 16, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Cemetery/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Cemetery/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#def2b6", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Freight/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Freight/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Water and wastewater/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Water and wastewater/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Port/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Port/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Industry/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Industry/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Government/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Government/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Finance/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Finance/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Emergency/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Emergency/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Indigenous/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Indigenous/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Military/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Military/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-padding": 25, + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Transportation/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Beach/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Beach/label", + "minzoom": 13, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.08, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#d9d1ba", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Golf course/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Golf course/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#def2b6", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Zoo/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Zoo/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#def2b6", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Retail/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Retail/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#e8cc99", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Landmark/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Landmark/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#e8cc99", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Openspace or forest/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Openspace or forest/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#def2b6", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Park or farming/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Park or farming/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-padding": 25, + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#def2b6", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Point of interest/Park", + "type": "symbol", + "source": "esri", + "source-layer": "Point of interest", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 9, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-anchor": "center", + "text-letter-spacing": 0.08, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 9, + 10 + ], + [ + 15, + 11 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#def2b6", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Education/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Education/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#e8cc99", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Medical/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Medical/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#f2c2c2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Admin1 forest or park/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Admin1 forest or park/label", + "minzoom": 6, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-padding": 25, + "text-size": { + "base": 1.2, + "stops": [ + [ + 6, + 10 + ], + [ + 7, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#def2b6", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Admin0 forest or park/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Admin0 forest or park/label", + "minzoom": 6, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-padding": 25, + "text-size": { + "base": 1.2, + "stops": [ + [ + 6, + 10 + ], + [ + 7, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#def2b6", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Airport/label/Airport property", + "type": "symbol", + "source": "esri", + "source-layer": "Airport/label", + "minzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-padding": 15, + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11 + ], + [ + 14, + 11.3 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Exit/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Exit", + "minzoom": 15, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-anchor": "center", + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "icon-image": "Exit/Default/{_len}", + "icon-rotation-alignment": "viewport", + "text-offset": [ + 0, + 0.3 + ], + "text-rotation-alignment": "viewport", + "text-size": { + "base": 1.2, + "stops": [ + [ + 15, + 9 + ], + [ + 17, + 10 + ] + ] + } + }, + "paint": { + "text-color": "#343434" + } + }, + { + "id": "Point of interest/General", + "type": "symbol", + "source": "esri", + "source-layer": "Point of interest", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 9, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-anchor": "center", + "text-letter-spacing": 0.08, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-padding": 20, + "text-size": { + "base": 1.2, + "stops": [ + [ + 9, + 10 + ], + [ + 15, + 11 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-width": 1.33, + "text-halo-color": "#000000" + } + }, + { + "id": "Building/label/Default", + "type": "symbol", + "source": "esri", + "source-layer": "Building/label", + "minzoom": 15, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.08, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-padding": 20, + "text-size": { + "base": 1.2, + "stops": [ + [ + 15, + 11 + ], + [ + 17, + 11.3 + ] + ] + } + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-color": "#000000", + "text-halo-width": 1.33 + } + }, + { + "id": "Point of interest/Bus station", + "type": "symbol", + "source": "esri", + "source-layer": "Point of interest", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 9, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "Point of interest/Bus station", + "icon-padding": 1, + "icon-size": { + "stops": [ + [ + 11, + 0.6 + ], + [ + 18, + 1.3 + ] + ] + }, + "text-font": [ + "Arial Regular" + ], + "text-anchor": "bottom", + "text-letter-spacing": 0.04, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-offset": [ + 0, + -0.5 + ], + "text-size": { + "base": 1.2, + "stops": [ + [ + 9, + 10 + ], + [ + 15, + 11 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-width": 1.33, + "text-halo-color": "#000000" + } + }, + { + "id": "Point of interest/Rail station", + "type": "symbol", + "source": "esri", + "source-layer": "Point of interest", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 9, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "Point of interest/Rail station", + "icon-padding": 1, + "icon-size": { + "stops": [ + [ + 11, + 0.6 + ], + [ + 18, + 1.3 + ] + ] + }, + "text-font": [ + "Arial Regular" + ], + "text-anchor": "bottom", + "text-letter-spacing": 0.04, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-offset": [ + 0, + -0.5 + ], + "text-size": { + "base": 1.2, + "stops": [ + [ + 9, + 10 + ], + [ + 15, + 11 + ], + [ + 17, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#b2b2b2", + "text-halo-width": 1.33, + "text-halo-color": "#000000" + } + }, + { + "id": "Place/Shop or Service/Gas Station", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 63 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Gas Station_1" + ], + [ + 16.6, + "Shop or Service place/Gas Station" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Pharmacy and Retail", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 107 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Pharmacy and Retail_1" + ], + [ + 16.6, + "Shop or Service place/Pharmacy and Retail" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Market", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 83 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Market_1" + ], + [ + 16.6, + "Shop or Service place/Market" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Grocery", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 66 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Grocery_1" + ], + [ + 16.6, + "Shop or Service place/Grocery" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/General Merchandise", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 64 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/General Merchandise_1" + ], + [ + 16.6, + "Shop or Service place/General Merchandise" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Department Store", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 41 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Department Store_1" + ], + [ + 16.6, + "Shop or Service place/Department Store" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Shop or Service/Shopping Center", + "type": "symbol", + "source": "esri", + "source-layer": "Shop or Service place", + "filter": [ + "==", + "_symbol", + 124 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Shop or Service place/Shopping Center_1" + ], + [ + 16.6, + "Shop or Service place/Shopping Center" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "top", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + 0.9 + ] + ], + [ + 16.6, + [ + 0, + 1 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Restaurant", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 78 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Restaurant_1" + ], + [ + 16.6, + "Food and Drink place/Restaurant" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Fast Food", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 41 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Fast Food_1" + ], + [ + 16.6, + "Food and Drink place/Fast Food" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Pizza", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 75 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Pizza_1" + ], + [ + 16.6, + "Food and Drink place/Pizza" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Brewery", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 15 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Brewery_1" + ], + [ + 16.6, + "Food and Drink place/Brewery" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Bar or Pub", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Bar or Pub_1" + ], + [ + 16.6, + "Food and Drink place/Bar or Pub" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Food and Drink/Coffee Shop", + "type": "symbol", + "source": "esri", + "source-layer": "Food and Drink place", + "filter": [ + "==", + "_symbol", + 32 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Food and Drink place/Coffee Shop_1" + ], + [ + 16.6, + "Food and Drink place/Coffee Shop" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Golf Course", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Golf Course_1" + ], + [ + 16.6, + "Sport place/Golf Course" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Sports Center", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 21 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Sports Center_1" + ], + [ + 16.6, + "Sport place/Sports Center" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Sport/Stadium", + "type": "symbol", + "source": "esri", + "source-layer": "Sport place", + "filter": [ + "==", + "_symbol", + 23 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Sport place/Stadium_1" + ], + [ + 16.6, + "Sport place/Stadium" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Religion/Church", + "type": "symbol", + "source": "esri", + "source-layer": "Religion place", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Religion place/Church_1" + ], + [ + 16.6, + "Religion place/Church" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Religion/Cemetery", + "type": "symbol", + "source": "esri", + "source-layer": "Religion place", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Religion place/Cemetery_1" + ], + [ + 16.6, + "Religion place/Cemetery" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#9cbe7c", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Lodging/Resort", + "type": "symbol", + "source": "esri", + "source-layer": "Lodging place", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Lodging place/Resort_1" + ], + [ + 16.6, + "Lodging place/Resort" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Lodging/Hotel", + "type": "symbol", + "source": "esri", + "source-layer": "Lodging place", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Lodging place/Hotel_1" + ], + [ + 16.6, + "Lodging place/Hotel" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.9 + ] + ], + [ + 16.6, + [ + 0, + -0.8 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Water Park", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 24 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Water Park_1" + ], + [ + 16.6, + "Outdoors place/Water Park" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Wild Animal Park", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 25 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Wild Animal Park_1" + ], + [ + 16.6, + "Outdoors place/Wild Animal Park" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#9cbe7c", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Dog Park", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Dog Park_1" + ], + [ + 16.6, + "Outdoors place/Dog Park" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#9cbe7c", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Park", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 11 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Park_1" + ], + [ + 16.6, + "Outdoors place/Park" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#9cbe7c", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Garden", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Garden_1" + ], + [ + 16.6, + "Outdoors place/Garden" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#9cbe7c", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Beach", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Beach_1" + ], + [ + 16.6, + "Outdoors place/Beach" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Wildlife Reserve", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 26 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Wildlife Reserve_1" + ], + [ + 16.6, + "Outdoors place/Wildlife Reserve" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#9cbe7c", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/Nature Reserve", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/Nature Reserve_1" + ], + [ + 16.6, + "Outdoors place/Nature Reserve" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#9cbe7c", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/State Park", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 21 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/State Park_1" + ], + [ + 16.6, + "Outdoors place/State Park" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#9cbe7c", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Outdoors/National Park", + "type": "symbol", + "source": "esri", + "source-layer": "Outdoors place", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Outdoors place/National Park_1" + ], + [ + 16.6, + "Outdoors place/National Park" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#9cbe7c", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Education/School", + "type": "symbol", + "source": "esri", + "source-layer": "Education place", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Education place/School_1" + ], + [ + 16.6, + "Education place/School" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Education/College or University", + "type": "symbol", + "source": "esri", + "source-layer": "Education place", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Education place/College or University_1" + ], + [ + 16.6, + "Education place/College or University" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Medical/Laboratory", + "type": "symbol", + "source": "esri", + "source-layer": "Medical place", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Medical place/Laboratory_1" + ], + [ + 16.6, + "Medical place/Laboratory" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Medical/Medical Clinic", + "type": "symbol", + "source": "esri", + "source-layer": "Medical place", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Medical place/Medical Clinic_1" + ], + [ + 16.6, + "Medical place/Medical Clinic" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#ff8c90", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Medical/Pharmacy", + "type": "symbol", + "source": "esri", + "source-layer": "Medical place", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Medical place/Pharmacy_1" + ], + [ + 16.6, + "Medical place/Pharmacy" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Medical/Hospital", + "type": "symbol", + "source": "esri", + "source-layer": "Medical place", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Medical place/Hospital_1" + ], + [ + 16.6, + "Medical place/Hospital" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#ff8c90", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Night Club", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 30 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Night Club_1" + ], + [ + 16.6, + "Arts and Entertainment place/Night Club" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Casino", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Casino_1" + ], + [ + 16.6, + "Arts and Entertainment place/Casino" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Cinema", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 10 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Cinema_1" + ], + [ + 16.6, + "Arts and Entertainment place/Cinema" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Amphitheater", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Amphitheater_1" + ], + [ + 16.6, + "Arts and Entertainment place/Amphitheater" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Observatory", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 32 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Observatory_1" + ], + [ + 16.6, + "Arts and Entertainment place/Observatory" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Performing Arts", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 34 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Performing Arts_1" + ], + [ + 16.6, + "Arts and Entertainment place/Performing Arts" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Art Museum", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Art Museum_1" + ], + [ + 16.6, + "Arts and Entertainment place/Art Museum" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/History Museum", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 23 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/History Museum_1" + ], + [ + 16.6, + "Arts and Entertainment place/History Museum" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Science Museum", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 39 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Science Museum_1" + ], + [ + 16.6, + "Arts and Entertainment place/Science Museum" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Museum", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 28 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Museum_1" + ], + [ + 16.6, + "Arts and Entertainment place/Museum" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Aquarium", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Aquarium_1" + ], + [ + 16.6, + "Arts and Entertainment place/Aquarium" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Zoo", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 45 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Zoo_1" + ], + [ + 16.6, + "Arts and Entertainment place/Zoo" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#9cbe7c", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Amusement Park", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Amusement Park_1" + ], + [ + 16.6, + "Arts and Entertainment place/Amusement Park" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.9 + ] + ], + [ + 16.6, + [ + 0, + -0.8 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Arts and Entertainment/Tourist Attraction", + "type": "symbol", + "source": "esri", + "source-layer": "Arts and Entertainment place", + "filter": [ + "==", + "_symbol", + 42 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Arts and Entertainment place/Tourist Attraction_1" + ], + [ + 16.6, + "Arts and Entertainment place/Tourist Attraction" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Community Center", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Community Center_1" + ], + [ + 16.6, + "Government or Public Facility place/Community Center" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Post Office", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 17 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Post Office_1" + ], + [ + 16.6, + "Government or Public Facility place/Post Office" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Military Base", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 11 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Military Base_1" + ], + [ + 16.6, + "Government or Public Facility place/Military Base" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Court House", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Court House_1" + ], + [ + 16.6, + "Government or Public Facility place/Court House" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Fire Station", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Fire Station_1" + ], + [ + 16.6, + "Government or Public Facility place/Fire Station" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Police Station", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 16 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Police Station_1" + ], + [ + 16.6, + "Government or Public Facility place/Police Station" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Convention Center", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Convention Center_1" + ], + [ + 16.6, + "Government or Public Facility place/Convention Center" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/Government Office", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/Government Office_1" + ], + [ + 16.6, + "Government or Public Facility place/Government Office" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Government or Public Facility/City Hall", + "type": "symbol", + "source": "esri", + "source-layer": "Government or Public Facility place", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Government or Public Facility place/City Hall_1" + ], + [ + 16.6, + "Government or Public Facility place/City Hall" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Landmark/Windmill", + "type": "symbol", + "source": "esri", + "source-layer": "Landmark place", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Landmark place/Windmill_1" + ], + [ + 16.6, + "Landmark place/Windmill" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Landmark/Shrine", + "type": "symbol", + "source": "esri", + "source-layer": "Landmark place", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Landmark place/Shrine_1" + ], + [ + 16.6, + "Landmark place/Shrine" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Landmark/Scenic Overlook", + "type": "symbol", + "source": "esri", + "source-layer": "Landmark place", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Landmark place/Scenic Overlook_1" + ], + [ + 16.6, + "Landmark place/Scenic Overlook" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Landmark/Ruin", + "type": "symbol", + "source": "esri", + "source-layer": "Landmark place", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Landmark place/Ruin_1" + ], + [ + 16.6, + "Landmark place/Ruin" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Landmark/Other Landmark", + "type": "symbol", + "source": "esri", + "source-layer": "Landmark place", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Landmark place/Other Landmark_1" + ], + [ + 16.6, + "Landmark place/Other Landmark" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Landmark/Memorial Site", + "type": "symbol", + "source": "esri", + "source-layer": "Landmark place", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Landmark place/Memorial Site_1" + ], + [ + 16.6, + "Landmark place/Memorial Site" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Landmark/Lighthouse", + "type": "symbol", + "source": "esri", + "source-layer": "Landmark place", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Landmark place/Lighthouse_1" + ], + [ + 16.6, + "Landmark place/Lighthouse" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Landmark/Historical Monument", + "type": "symbol", + "source": "esri", + "source-layer": "Landmark place", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 11, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Landmark place/Historical Monument_1" + ], + [ + 16.6, + "Landmark place/Historical Monument" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Other Transportation", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 19 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Other Transportation_1" + ], + [ + 16.6, + "Transportation place/Other Transportation" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Cargo Center", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Cargo Center_1" + ], + [ + 16.6, + "Transportation place/Cargo Center" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Weigh Station", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 34 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Weigh Station_1" + ], + [ + 16.6, + "Transportation place/Weigh Station" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Truck Parking", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 30 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Truck Parking_1" + ], + [ + 16.6, + "Transportation place/Truck Parking" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Truck Stop", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 31 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Truck Stop_1" + ], + [ + 16.6, + "Transportation place/Truck Stop" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Off-Road Vehicle Area", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 18 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Off-Road Vehicle Area_1" + ], + [ + 16.6, + "Transportation place/Off-Road Vehicle Area" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Off Road Trailhead", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 17 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Off Road Trailhead_1" + ], + [ + 16.6, + "Transportation place/Off Road Trailhead" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Rail Ferry", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 23 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Rail Ferry_1" + ], + [ + 16.6, + "Transportation place/Rail Ferry" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Taxi", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 27 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Taxi_1" + ], + [ + 16.6, + "Transportation place/Taxi" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Railyard", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 25 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Railyard_1" + ], + [ + 16.6, + "Transportation place/Railyard" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Boating", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Boating_1" + ], + [ + 16.6, + "Transportation place/Boating" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Gondola", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 10 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Gondola_1" + ], + [ + 16.6, + "Transportation place/Gondola" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Dock", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Dock_1" + ], + [ + 16.6, + "Transportation place/Dock" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Pier", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 21 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Pier_1" + ], + [ + 16.6, + "Transportation place/Pier" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Port", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 22 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Port_1" + ], + [ + 16.6, + "Transportation place/Port" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Marina", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 15 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Marina_1" + ], + [ + 16.6, + "Transportation place/Marina" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Water Transit", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 33 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Water Transit_1" + ], + [ + 16.6, + "Transportation place/Water Transit" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Ferry Terminal", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Ferry Terminal_1" + ], + [ + 16.6, + "Transportation place/Ferry Terminal" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Border Crossing", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Border Crossing_1" + ], + [ + 16.6, + "Transportation place/Border Crossing" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Rest Area", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 26 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Rest Area_1" + ], + [ + 16.6, + "Transportation place/Rest Area" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Tollbooth", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 28 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Tollbooth_1" + ], + [ + 16.6, + "Transportation place/Tollbooth" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Highway Exit", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 12 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Highway Exit_1" + ], + [ + 16.6, + "Transportation place/Highway Exit" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Tunnel", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 32 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Tunnel_1" + ], + [ + 16.6, + "Transportation place/Tunnel" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Bridge", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Bridge_1" + ], + [ + 16.6, + "Transportation place/Bridge" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Bicycle Sharing Location", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Bicycle Sharing Location_1" + ], + [ + 16.6, + "Transportation place/Bicycle Sharing Location" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Parking", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 20 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Parking_1" + ], + [ + 16.6, + "Transportation place/Parking" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Heliport", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 11 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Heliport_1" + ], + [ + 16.6, + "Transportation place/Heliport" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Airport Terminal", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Airport Terminal_1" + ], + [ + 16.6, + "Transportation place/Airport Terminal" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Airport", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Airport_1" + ], + [ + 16.6, + "Transportation place/Airport" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#69beb9", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Local Transit", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 14 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Local Transit_1" + ], + [ + 16.6, + "Transportation place/Local Transit" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#69beb9", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Bus Station", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Bus Station_1" + ], + [ + 16.6, + "Transportation place/Bus Station" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#69beb9", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Metro Station", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 16 + ], + "minzoom": 14, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Metro Station_1" + ], + [ + 16.6, + "Transportation place/Metro Station" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#69beb9", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Lightrail", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 13 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Lightrail_1" + ], + [ + 16.6, + "Transportation place/Lightrail" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#69beb9", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Rail Station", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 24 + ], + "minzoom": 1, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Rail Station_1" + ], + [ + 16.6, + "Transportation place/Rail Station" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#69beb9", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Transportation/Train Station", + "type": "symbol", + "source": "esri", + "source-layer": "Transportation place", + "filter": [ + "==", + "_symbol", + 29 + ], + "minzoom": 14, + "layout": { + "icon-image": { + "stops": [ + [ + 16.5, + "Transportation place/Train Station_1" + ], + [ + 16.6, + "Transportation place/Train Station" + ] + ] + }, + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#69beb9", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Indoors/Stairs", + "type": "symbol", + "source": "esri", + "source-layer": "Indoors place", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 17, + "layout": { + "icon-image": "Indoors place/Stairs", + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Indoors/Other Indoors", + "type": "symbol", + "source": "esri", + "source-layer": "Indoors place", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 17, + "layout": { + "icon-image": "Indoors place/Other Indoors", + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Indoors/Fire Extinguisher", + "type": "symbol", + "source": "esri", + "source-layer": "Indoors place", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 17, + "layout": { + "icon-image": "Indoors place/Fire Extinguisher", + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Indoors/Elevator", + "type": "symbol", + "source": "esri", + "source-layer": "Indoors place", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 17, + "layout": { + "icon-image": "Indoors place/Elevator", + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Place/Indoors/Delivery Entrance", + "type": "symbol", + "source": "esri", + "source-layer": "Indoors place", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 17, + "layout": { + "icon-image": "Indoors place/Delivery Entrance", + "text-font": [ + "Arial Bold", + "Arial Unicode MS Bold" + ], + "text-size": { + "stops": [ + [ + 16.5, + 10 + ], + [ + 16.6, + 12 + ] + ] + }, + "text-anchor": "bottom", + "text-offset": { + "stops": [ + [ + 16.5, + [ + 0, + -0.8 + ] + ], + [ + 16.6, + [ + 0, + -0.9 + ] + ] + ] + }, + "text-field": "{_name}", + "text-max-width": { + "stops": [ + [ + 16.5, + 7 + ], + [ + 16.6, + 9 + ] + ] + }, + "text-line-height": 1.1, + "visibility": "none" + }, + "paint": { + "text-color": "#CCCCCC", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Admin2 area/label/small", + "type": "symbol", + "source": "esri", + "source-layer": "Admin2 area/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 10, + "maxzoom": 12, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-size": 11, + "text-letter-spacing": 0.2, + "text-max-width": 8, + "text-field": "{_name}" + }, + "paint": { + "text-color": "#ffffd9", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "Admin2 area/label/large", + "type": "symbol", + "source": "esri", + "source-layer": "Admin2 area/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 10, + "maxzoom": 12, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-size": 12.5, + "text-letter-spacing": 0.2, + "text-max-width": 8, + "text-field": "{_name}" + }, + "paint": { + "text-color": "#ffffd9", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "Admin1 area/label/x small", + "type": "symbol", + "source": "esri", + "source-layer": "Admin1 area/label", + "filter": [ + "==", + "_label_class", + 5 + ], + "minzoom": 3, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-max-width": 8, + "text-field": "{_name}", + "text-size": { + "stops": [ + [ + 3, + 9 + ], + [ + 10, + 11 + ] + ] + } + }, + "paint": { + "text-color": { + "stops": [ + [ + 5, + "#cecdcd" + ], + [ + 7, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "Admin1 area/label/small", + "type": "symbol", + "source": "esri", + "source-layer": "Admin1 area/label", + "filter": [ + "==", + "_label_class", + 4 + ], + "minzoom": 3, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-max-width": 8, + "text-field": "{_name}", + "text-size": { + "stops": [ + [ + 3, + 9 + ], + [ + 10, + 11 + ] + ] + } + }, + "paint": { + "text-color": { + "stops": [ + [ + 5, + "#cecdcd" + ], + [ + 7, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "Admin1 area/label/medium", + "type": "symbol", + "source": "esri", + "source-layer": "Admin1 area/label", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 3, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-max-width": 8, + "text-field": "{_name}", + "text-size": { + "stops": [ + [ + 3, + 9 + ], + [ + 10, + 13 + ] + ] + } + }, + "paint": { + "text-color": { + "stops": [ + [ + 5, + "#cecdcd" + ], + [ + 7, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "Admin1 area/label/large", + "type": "symbol", + "source": "esri", + "source-layer": "Admin1 area/label", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 3, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-max-width": 8, + "text-field": "{_name}", + "text-size": { + "stops": [ + [ + 3, + 10 + ], + [ + 10, + 13 + ] + ] + }, + "text-letter-spacing": 0.1 + }, + "paint": { + "text-color": { + "stops": [ + [ + 5, + "#cecdcd" + ], + [ + 7, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "Admin1 area/label/x large", + "type": "symbol", + "source": "esri", + "source-layer": "Admin1 area/label", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 3, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-max-width": 8, + "text-field": "{_name}", + "text-size": { + "stops": [ + [ + 3, + 10 + ], + [ + 10, + 16 + ] + ] + }, + "text-letter-spacing": 0.1 + }, + "paint": { + "text-color": { + "stops": [ + [ + 5, + "#cecdcd" + ], + [ + 7, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "Admin1 area/label/2x large", + "type": "symbol", + "source": "esri", + "source-layer": "Admin1 area/label", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 3, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-max-width": 8, + "text-field": "{_name}", + "text-size": { + "stops": [ + [ + 3, + 11 + ], + [ + 10, + 16 + ] + ] + }, + "text-letter-spacing": 0.1 + }, + "paint": { + "text-color": { + "stops": [ + [ + 5, + "#cecdcd" + ], + [ + 7, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "Admin0 point/x small", + "type": "symbol", + "source": "esri", + "source-layer": "Admin0 point", + "filter": [ + "==", + "_label_class", + 5 + ], + "minzoom": 5, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-anchor": "center", + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name}", + "text-size": { + "stops": [ + [ + 5, + 11 + ], + [ + 10, + 12.5 + ] + ] + }, + "text-transform": "uppercase" + }, + "paint": { + "text-color": { + "stops": [ + [ + 5, + "#cecdcd" + ], + [ + 7, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "Admin0 point/small", + "type": "symbol", + "source": "esri", + "source-layer": "Admin0 point", + "filter": [ + "==", + "_label_class", + 4 + ], + "minzoom": 4, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-anchor": "center", + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name}", + "text-size": { + "stops": [ + [ + 4, + 10 + ], + [ + 10, + 12.5 + ] + ] + }, + "text-transform": "uppercase" + }, + "paint": { + "text-color": { + "stops": [ + [ + 5, + "#cecdcd" + ], + [ + 7, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "Admin0 point/medium", + "type": "symbol", + "source": "esri", + "source-layer": "Admin0 point", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-anchor": "center", + "text-letter-spacing": 0.05, + "text-max-width": 6, + "text-field": "{_name}", + "text-size": { + "stops": [ + [ + 2, + 9.5 + ], + [ + 10, + 15.5 + ] + ] + }, + "text-transform": "uppercase" + }, + "paint": { + "text-color": { + "stops": [ + [ + 5, + "#cecdcd" + ], + [ + 7, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "Admin0 point/large", + "type": "symbol", + "source": "esri", + "source-layer": "Admin0 point", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-anchor": "center", + "text-letter-spacing": 0.1, + "text-max-width": 8, + "text-field": "{_name}", + "text-size": { + "stops": [ + [ + 2, + 9.5 + ], + [ + 10, + 15.5 + ] + ] + }, + "text-transform": "uppercase" + }, + "paint": { + "text-color": { + "stops": [ + [ + 5, + "#cecdcd" + ], + [ + 7, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "Admin0 point/x large", + "type": "symbol", + "source": "esri", + "source-layer": "Admin0 point", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-anchor": "center", + "text-letter-spacing": 0.12, + "text-max-width": 8, + "text-field": "{_name}", + "text-size": { + "stops": [ + [ + 2, + 9.5 + ], + [ + 10, + 18 + ] + ] + }, + "text-transform": "uppercase" + }, + "paint": { + "text-color": { + "stops": [ + [ + 5, + "#cecdcd" + ], + [ + 7, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "Admin0 point/2x large", + "type": "symbol", + "source": "esri", + "source-layer": "Admin0 point", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "text-font": [ + "Arial Bold" + ], + "text-anchor": "center", + "text-letter-spacing": 0.15, + "text-max-width": 8, + "text-field": "{_name}", + "text-size": { + "stops": [ + [ + 2, + 12 + ], + [ + 10, + 18 + ] + ] + }, + "text-transform": "uppercase" + }, + "paint": { + "text-color": { + "stops": [ + [ + 5, + "#cecdcd" + ], + [ + 7, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "Neighborhood", + "type": "symbol", + "source": "esri", + "source-layer": "Neighborhood", + "minzoom": 11, + "maxzoom": 18, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "Neighborhood", + "text-font": [ + "Arial Regular" + ], + "text-size": { + "stops": [ + [ + 11, + 8 + ], + [ + 18, + 18 + ] + ] + }, + "text-letter-spacing": 0.08, + "text-max-width": 8, + "text-padding": { + "stops": [ + [ + 11, + 20 + ], + [ + 18, + 2 + ] + ] + }, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 2 + } + }, + { + "id": "City large scale/town small", + "type": "symbol", + "source": "esri", + "source-layer": "City large scale", + "filter": [ + "==", + "_label_class", + 5 + ], + "minzoom": 10, + "maxzoom": 18, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City large scale", + "text-font": [ + "Arial Regular" + ], + "text-size": { + "stops": [ + [ + 10, + 11.3 + ], + [ + 15, + 16 + ], + [ + 17, + 20 + ] + ] + }, + "text-letter-spacing": 0.08, + "text-max-width": 8, + "text-padding": { + "stops": [ + [ + 10, + 20 + ], + [ + 18, + 2 + ] + ] + }, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 2 + } + }, + { + "id": "City large scale/town large", + "type": "symbol", + "source": "esri", + "source-layer": "City large scale", + "filter": [ + "==", + "_label_class", + 4 + ], + "minzoom": 10, + "maxzoom": 17, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City large scale", + "text-font": [ + "Arial Regular" + ], + "text-letter-spacing": 0.09, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-padding": { + "stops": [ + [ + 10, + 20 + ], + [ + 17, + 2 + ] + ] + }, + "text-size": { + "stops": [ + [ + 10, + 11.3 + ], + [ + 12, + 12 + ], + [ + 15, + 18 + ], + [ + 16, + 20 + ] + ] + } + }, + "paint": { + "text-halo-color": "#000000", + "text-halo-width": 2, + "text-color": { + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 13, + "#ffffd9" + ] + ] + } + } + }, + { + "id": "City large scale/small", + "type": "symbol", + "source": "esri", + "source-layer": "City large scale", + "filter": [ + "==", + "_label_class", + 3 + ], + "minzoom": 10, + "maxzoom": 17, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City large scale", + "text-font": [ + "Arial Bold" + ], + "text-letter-spacing": 0.1, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 11.3 + ], + [ + 12, + 12 + ], + [ + 15, + 18 + ] + ] + } + }, + "paint": { + "text-halo-color": "#000000", + "text-halo-width": 2, + "text-color": { + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 13, + "#ffffd9" + ] + ] + } + } + }, + { + "id": "City large scale/medium", + "type": "symbol", + "source": "esri", + "source-layer": "City large scale", + "filter": [ + "==", + "_label_class", + 2 + ], + "minzoom": 10, + "maxzoom": 17, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City large scale", + "text-font": [ + "Arial Bold" + ], + "text-letter-spacing": 0.1, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 12 + ], + [ + 12, + 12.67 + ], + [ + 14, + 20 + ] + ] + } + }, + "paint": { + "text-halo-color": "#000000", + "text-halo-width": 2, + "text-color": { + "stops": [ + [ + 10, + "#ffffff" + ], + [ + 11, + "#ffffd9" + ] + ] + } + } + }, + { + "id": "City large scale/large", + "type": "symbol", + "source": "esri", + "source-layer": "City large scale", + "filter": [ + "==", + "_label_class", + 1 + ], + "minzoom": 10, + "maxzoom": 17, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City large scale", + "text-font": [ + "Arial Bold" + ], + "text-size": { + "stops": [ + [ + 10, + 15 + ], + [ + 14, + 22 + ] + ] + }, + "text-letter-spacing": 0.1, + "text-max-width": 8, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffcc", + "text-halo-color": "#000000", + "text-halo-width": 2 + } + }, + { + "id": "City large scale/x large", + "type": "symbol", + "source": "esri", + "source-layer": "City large scale", + "filter": [ + "==", + "_label_class", + 0 + ], + "minzoom": 10, + "maxzoom": 17, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City large scale", + "text-font": [ + "Arial Bold" + ], + "text-size": { + "stops": [ + [ + 10, + 15 + ], + [ + 14, + 25 + ] + ] + }, + "text-letter-spacing": 0.1, + "text-max-width": 8, + "text-field": "{_name_global}" + }, + "paint": { + "text-color": "#ffffcc", + "text-halo-color": "#000000", + "text-halo-width": 2 + } + }, + { + "id": "City small scale/town small non capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 17 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/town small non capital", + "icon-padding": 1, + "text-font": [ + "Arial Regular" + ], + "text-size": 10.67, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "City small scale/town large non capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 15 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/town large non capital", + "icon-padding": 1, + "text-font": [ + "Arial Regular" + ], + "text-size": 10.67, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "City small scale/small non capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 12 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/small non capital", + "icon-padding": 1, + "text-font": [ + "Arial Regular" + ], + "text-size": 10.67, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "City small scale/medium non capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 9 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/medium non capital", + "icon-padding": 1, + "text-font": [ + "Arial Bold" + ], + "text-size": 10.67, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "City small scale/other capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 18 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/other capital", + "icon-padding": 1, + "text-font": [ + "Arial Regular" + ], + "text-size": 10.67, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "City small scale/town large other capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 14 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/town large other capital", + "icon-padding": 1, + "text-font": [ + "Arial Regular" + ], + "text-size": 10.67, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "City small scale/small other capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 11 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/small other capital", + "icon-padding": 1, + "text-font": [ + "Arial Regular" + ], + "text-size": 10.67, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "City small scale/medium other capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 8 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/medium other capital", + "icon-padding": 1, + "text-font": [ + "Arial Bold" + ], + "text-size": 10.67, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "City small scale/town small admin0 capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 16 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/town small admin0 capital", + "icon-padding": 1, + "text-font": [ + "Arial Regular" + ], + "text-size": 10.67, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": "#ffffd9", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "City small scale/town large admin0 capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 13 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/town large admin0 capital", + "icon-padding": 1, + "text-font": [ + "Arial Regular" + ], + "text-size": 10.67, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": "#ffffd9", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "City small scale/small admin0 capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 10 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/small admin0 capital", + "icon-padding": 1, + "text-font": [ + "Arial Regular" + ], + "text-size": 10.67, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": "#ffffd9", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "City small scale/medium admin0 capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 7 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/medium admin0 capital", + "icon-padding": 1, + "text-font": [ + "Arial Bold" + ], + "text-size": 10.67, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": "#ffffd9", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "City small scale/large other capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 5 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/large other capital", + "icon-padding": 1, + "text-font": [ + "Arial Bold" + ], + "text-size": 10.67, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "City small scale/x large admin2 capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 2 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/x large admin2 capital", + "icon-padding": 1, + "text-font": [ + "Arial Bold" + ], + "text-size": 12, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": { + "stops": [ + [ + 2, + "#ffffff" + ], + [ + 6, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "City small scale/large non capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 6 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/large non capital", + "icon-padding": 1, + "text-font": [ + "Arial Bold" + ], + "text-size": 10.67, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": "#ffffff", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "City small scale/large admin0 capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 4 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/large admin0 capital", + "icon-padding": 1, + "text-font": [ + "Arial Bold" + ], + "text-size": 10.67, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": "#ffffd9", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "City small scale/x large non capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 3 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/x large non capital", + "icon-padding": 1, + "text-font": [ + "Arial Bold" + ], + "text-size": 12, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": { + "stops": [ + [ + 2, + "#ffffff" + ], + [ + 6, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "City small scale/x large admin1 capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 1 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/x large admin1 capital", + "icon-padding": 1, + "text-font": [ + "Arial Bold" + ], + "text-size": 12, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": { + "stops": [ + [ + 2, + "#ffffff" + ], + [ + 6, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "City small scale/x large admin0 capital", + "type": "symbol", + "source": "esri", + "source-layer": "City small scale", + "filter": [ + "==", + "_symbol", + 0 + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "City small scale/x large admin0 capital", + "icon-padding": 1, + "text-font": [ + "Arial Bold" + ], + "text-size": 12, + "text-anchor": "bottom-left", + "text-justify": "left", + "text-max-width": 8, + "text-field": "{_name}", + "text-offset": [ + 0.13, + -0.13 + ] + }, + "paint": { + "text-color": "#ffffd9", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "Continent", + "type": "symbol", + "source": "esri", + "source-layer": "Continent", + "minzoom": 0, + "maxzoom": 2, + "layout": { + "symbol-avoid-edges": true, + "icon-image": "Continent", + "icon-allow-overlap": true, + "icon-padding": 1, + "text-font": [ + "Arial Regular" + ], + "text-size": { + "stops": [ + [ + 0, + 9 + ], + [ + 1, + 12 + ] + ] + }, + "text-anchor": "center", + "text-letter-spacing": 0.05, + "text-max-width": 8, + "text-field": "{_name_global}", + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#ffffd9", + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + }, + { + "id": "Disputed label point/Island", + "type": "symbol", + "source": "esri", + "source-layer": "Disputed label point", + "filter": [ + "all", + [ + "==", + "_label_class", + 1 + ], + [ + "in", + "DisputeID", + 0 + ] + ], + "minzoom": 6, + "layout": { + "icon-image": "Disputed label point", + "icon-allow-overlap": true, + "text-font": [ + "Arial Italic" + ], + "text-size": 11.5, + "text-anchor": "center", + "text-letter-spacing": 0.08, + "text-max-width": 7, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#d9d1ba", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Disputed label point/Waterbody", + "type": "symbol", + "source": "esri", + "source-layer": "Disputed label point", + "filter": [ + "all", + [ + "==", + "_label_class", + 0 + ], + [ + "in", + "DisputeID", + 1006 + ] + ], + "minzoom": 2, + "maxzoom": 10, + "layout": { + "icon-image": "Disputed label point", + "icon-allow-overlap": true, + "text-font": [ + "Arial Italic" + ], + "text-size": 11, + "text-anchor": "center", + "text-letter-spacing": 0.1, + "text-max-width": 7, + "text-field": "{_name}", + "text-optional": true + }, + "paint": { + "text-color": "#9ecccc", + "text-halo-color": "#000000", + "text-halo-width": 1 + } + }, + { + "id": "Disputed label point/Admin0", + "type": "symbol", + "source": "esri", + "source-layer": "Disputed label point", + "filter": [ + "all", + [ + "==", + "_label_class", + 2 + ], + [ + "in", + "DisputeID", + 1021 + ] + ], + "minzoom": 2, + "layout": { + "icon-image": "Disputed label point", + "icon-allow-overlap": true, + "text-font": [ + "Arial Bold" + ], + "text-size": { + "stops": [ + [ + 2, + 9.5 + ], + [ + 10, + 15.5 + ] + ] + }, + "text-anchor": "center", + "text-letter-spacing": 0.1, + "text-max-width": 8, + "text-field": "{_name}", + "text-transform": "uppercase", + "text-optional": true + }, + "paint": { + "text-color": { + "stops": [ + [ + 2, + "#cecdcd" + ], + [ + 7, + "#ffffd9" + ] + ] + }, + "text-halo-color": "#000000", + "text-halo-width": 1.2 + } + } + ] +} diff --git a/ui-ngx/src/assets/markers/iconContainer1.svg b/ui-ngx/src/assets/markers/iconContainer1.svg new file mode 100644 index 0000000000..d9e00d3934 --- /dev/null +++ b/ui-ngx/src/assets/markers/iconContainer1.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/iconContainer2.svg b/ui-ngx/src/assets/markers/iconContainer2.svg new file mode 100644 index 0000000000..fa716aa30e --- /dev/null +++ b/ui-ngx/src/assets/markers/iconContainer2.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/iconContainer3.svg b/ui-ngx/src/assets/markers/iconContainer3.svg new file mode 100644 index 0000000000..a64995d1ee --- /dev/null +++ b/ui-ngx/src/assets/markers/iconContainer3.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape1.svg b/ui-ngx/src/assets/markers/shape1.svg new file mode 100644 index 0000000000..7399bab288 --- /dev/null +++ b/ui-ngx/src/assets/markers/shape1.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape10.svg b/ui-ngx/src/assets/markers/shape10.svg new file mode 100644 index 0000000000..d13fca2d2b --- /dev/null +++ b/ui-ngx/src/assets/markers/shape10.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape2.svg b/ui-ngx/src/assets/markers/shape2.svg new file mode 100644 index 0000000000..c60d909881 --- /dev/null +++ b/ui-ngx/src/assets/markers/shape2.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape3.svg b/ui-ngx/src/assets/markers/shape3.svg new file mode 100644 index 0000000000..e1f0a8ac1b --- /dev/null +++ b/ui-ngx/src/assets/markers/shape3.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape4.svg b/ui-ngx/src/assets/markers/shape4.svg new file mode 100644 index 0000000000..4190ce6d06 --- /dev/null +++ b/ui-ngx/src/assets/markers/shape4.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape5.svg b/ui-ngx/src/assets/markers/shape5.svg new file mode 100644 index 0000000000..e1080d7e19 --- /dev/null +++ b/ui-ngx/src/assets/markers/shape5.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape6.svg b/ui-ngx/src/assets/markers/shape6.svg new file mode 100644 index 0000000000..b388f13bdf --- /dev/null +++ b/ui-ngx/src/assets/markers/shape6.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape7.svg b/ui-ngx/src/assets/markers/shape7.svg new file mode 100644 index 0000000000..99e2a5a623 --- /dev/null +++ b/ui-ngx/src/assets/markers/shape7.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape8.svg b/ui-ngx/src/assets/markers/shape8.svg new file mode 100644 index 0000000000..a303a36ba3 --- /dev/null +++ b/ui-ngx/src/assets/markers/shape8.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape9.svg b/ui-ngx/src/assets/markers/shape9.svg new file mode 100644 index 0000000000..52d1a03fa0 --- /dev/null +++ b/ui-ngx/src/assets/markers/shape9.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripIconContainer1.svg b/ui-ngx/src/assets/markers/tripIconContainer1.svg new file mode 100644 index 0000000000..c1186b2aa9 --- /dev/null +++ b/ui-ngx/src/assets/markers/tripIconContainer1.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripIconContainer2.svg b/ui-ngx/src/assets/markers/tripIconContainer2.svg new file mode 100644 index 0000000000..229c98b31e --- /dev/null +++ b/ui-ngx/src/assets/markers/tripIconContainer2.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripIconContainer3.svg b/ui-ngx/src/assets/markers/tripIconContainer3.svg new file mode 100644 index 0000000000..8a8baf2b1b --- /dev/null +++ b/ui-ngx/src/assets/markers/tripIconContainer3.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripShape1.svg b/ui-ngx/src/assets/markers/tripShape1.svg new file mode 100644 index 0000000000..da98975b52 --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui-ngx/src/assets/markers/tripShape10.svg b/ui-ngx/src/assets/markers/tripShape10.svg new file mode 100644 index 0000000000..5fb5d81af1 --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape10.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripShape2.svg b/ui-ngx/src/assets/markers/tripShape2.svg new file mode 100644 index 0000000000..e53beaf187 --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape2.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripShape3.svg b/ui-ngx/src/assets/markers/tripShape3.svg new file mode 100644 index 0000000000..ca81dc38e6 --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape3.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripShape4.svg b/ui-ngx/src/assets/markers/tripShape4.svg new file mode 100644 index 0000000000..ff06ce5075 --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape4.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripShape5.svg b/ui-ngx/src/assets/markers/tripShape5.svg new file mode 100644 index 0000000000..e774b4f92c --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape5.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripShape6.svg b/ui-ngx/src/assets/markers/tripShape6.svg new file mode 100644 index 0000000000..aeb7d7f3f5 --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape6.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripShape7.svg b/ui-ngx/src/assets/markers/tripShape7.svg new file mode 100644 index 0000000000..38d35f6932 --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape7.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripShape8.svg b/ui-ngx/src/assets/markers/tripShape8.svg new file mode 100644 index 0000000000..60fdc493c5 --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape8.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripShape9.svg b/ui-ngx/src/assets/markers/tripShape9.svg new file mode 100644 index 0000000000..02521777af --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape9.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index e42fac19c3..8f3c7f0cd0 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -163,6 +163,13 @@ .tb-form-panel-title { font-weight: 500; font-size: 16px; + + &.tb-required::after { + font-size: 13px; + color: rgba(0, 0, 0, .54); + vertical-align: top; + content: " *"; + } } .tb-form-panel-hint { font-size: 12px; diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss index a47dfe8c70..539a3b1960 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -689,7 +689,7 @@ pre.tb-highlight { } } - mat-toolbar.mat-mdc-table-toolbar:not(.mat-primary), .mat-mdc-cell, .mat-expansion-panel-header { + mat-toolbar.mat-mdc-table-toolbar:not(.mat-primary), .mat-mdc-cell, .mat-expansion-panel-header, mat-card-header.mat-mdc-card-header { button.mat-mdc-icon-button { .mat-icon { color: rgba(0, 0, 0, .54); diff --git a/ui-ngx/src/typings/jquery.jstree.typings.d.ts b/ui-ngx/src/typings/jquery.jstree.typings.d.ts index 0aa6a0b92e..933a9c60d5 100644 --- a/ui-ngx/src/typings/jquery.jstree.typings.d.ts +++ b/ui-ngx/src/typings/jquery.jstree.typings.d.ts @@ -27,6 +27,7 @@ interface JQuery { interface JSTreeEventData { instance: JSTree; + action: string; } interface JSTreeModelEventData extends JSTreeEventData { diff --git a/ui-ngx/src/typings/leaflet-extend-tb.d.ts b/ui-ngx/src/typings/leaflet-extend-tb.d.ts index af03caaa04..1ea249b478 100644 --- a/ui-ngx/src/typings/leaflet-extend-tb.d.ts +++ b/ui-ngx/src/typings/leaflet-extend-tb.d.ts @@ -15,10 +15,194 @@ /// import { FormattedData } from '@shared/models/widget.models'; +import L from 'leaflet'; +import { Map as MapLibreGLMap, MapOptions as MapLibreGLMapOptions } from 'maplibre-gl'; +import { TbMapDatasource } from '@shared/models/widget/maps/map.models'; +import { MatIconRegistry } from '@angular/material/icon'; // redeclare module, maintains compatibility with @types/leaflet declare module 'leaflet' { interface MarkerOptions { - tbMarkerData?: FormattedData; + tbMarkerData?: FormattedData; + } + + interface TileLayer { + _url: string; + _getSubdomain(tilePoint: L.Coords): string; + _globalTileRange: L.Bounds; + } + + namespace TB { + + interface SidebarControlOptions extends ControlOptions { + container: JQuery; + paneWidth?: number; + } + + class SidebarControl extends Control { + constructor(options: SidebarControlOptions); + addPane(pane: JQuery, button: JQuery): this; + togglePane(pane: JQuery, button: JQuery): void; + } + + interface SidebarPaneControlOptions extends ControlOptions { + sidebar: SidebarControl; + uiClass: string; + buttonTitle?: string; + paneTitle: string; + } + + class SidebarPaneControl extends Control { + constructor(options: O); + onAddPane(map: Map, button: JQuery, $ui: JQuery, toggle: (e: JQuery.MouseEventBase) => void); + } + + interface LayerData { + title: string; + attributionPrefix?: string; + layer: Layer; + mini: Layer; + } + + interface LayersControlOptions extends SidebarPaneControlOptions { + layers: LayerData[]; + } + + class LayersControl extends SidebarPaneControl { + constructor(options: LayersControlOptions); + } + + interface DataLayer { + toggleGroup(group: string): boolean; + } + + interface GroupData { + title: string; + group: string; + enabled: boolean; + dataLayers: DataLayer[]; + } + + interface GroupsControlOptions extends SidebarPaneControlOptions { + groups: GroupData[]; + } + + class GroupsControl extends SidebarPaneControl { + constructor(options: GroupsControlOptions); + } + + interface TopToolbarButtonOptions { + icon: string; + color?: string; + title: string; + } + + class TopToolbarButton { + constructor(options: TopToolbarButtonOptions, iconRegistry: MatIconRegistry); + onClick(onClick: (e: MouseEvent, button: TopToolbarButton) => void): void; + setActive(active: boolean): void; + isActive(): boolean; + setDisabled(disabled: boolean): void; + isDisabled(): boolean; + } + + interface TopToolbarControlOptions { + mapElement: JQuery; + iconRegistry: MatIconRegistry; + } + + class TopToolbarControl { + constructor(options: TopToolbarControlOptions); + toolbarButton(options: TopToolbarButtonOptions): TopToolbarButton; + setDisabled(disabled: boolean): void; + } + + interface ToolbarButtonOptions { + id: string; + title: string; + click: (e: MouseEvent, button: ToolbarButton) => void; + iconClass: string; + showText?: boolean; + } + + class ToolbarButton { + constructor(options: ToolbarButtonOptions); + setActive(active: boolean): void; + isActive(): boolean; + setDisabled(disabled: boolean): void; + isDisabled(): boolean; + } + + class ToolbarControl extends Control { + constructor(options: ControlOptions); + toolbarButton(options: ToolbarButtonOptions): ToolbarButton; + } + + interface BottomToolbarControlOptions { + mapElement: JQuery; + closeTitle: string; + onClose: () => boolean; + } + + class BottomToolbarControl { + constructor(options: BottomToolbarControlOptions); + getButton(id: string): ToolbarButton | undefined; + open(buttons: ToolbarButtonOptions[], showCloseButton?: boolean): void; + close(): void; + container: HTMLElement; + } + + function sidebar(options: SidebarControlOptions): SidebarControl; + + function sidebarPane(options: O): SidebarPaneControl; + + function layers(options: LayersControlOptions): LayersControl; + + function groups(options: GroupsControlOptions): GroupsControl; + + function topToolbar(options: TopToolbarControlOptions): TopToolbarControl; + + function toolbar(options: ControlOptions): ToolbarControl; + + function bottomToolbar(options: BottomToolbarControlOptions): BottomToolbarControl; + + namespace TileLayer { + + interface ChinaProvidersData { + [provider: string]: { + [type: string]: string; + Subdomains: string; + }; + } + + class ChinaProvider extends L.TileLayer { + constructor(type: string, options?: TileLayerOptions); + } + } + + namespace tileLayer { + function chinaProvider(type: string, options?: TileLayerOptions): TileLayer.ChinaProvider; + } + + namespace MapLibreGL { + + interface LeafletMapLibreGLMapOptions extends L.InteractiveLayerOptions, Omit { + updateInterval?: number; + padding?: number; + className?: string; + } + + class MapLibreGLLayer extends L.Layer { + constructor(options: LeafletMapLibreGLMapOptions); + getMapLibreGLMap(): MapLibreGLMap + getCanvas(): HTMLCanvasElement + getSize(): L.Point + getBounds(): L.LatLngBounds + getContainer(): HTMLDivElement + getPaneName(): string + } + + function mapLibreGLLayer(options: LeafletMapLibreGLMapOptions): MapLibreGLLayer; + } } } diff --git a/ui-ngx/tailwind.config.js b/ui-ngx/tailwind.config.js index 5933ab8a14..e84ba47abc 100644 --- a/ui-ngx/tailwind.config.js +++ b/ui-ngx/tailwind.config.js @@ -180,5 +180,8 @@ module.exports = { preflight: false }, plugins: [], + experimental: { + optimizeUniversalDefaults: true + } } diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 01a0062a39..04e0c4f77a 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -307,13 +307,6 @@ dependencies: tslib "^2.3.0" -"@angular/flex-layout@^15.0.0-beta.42": - version "15.0.0-beta.42" - resolved "https://registry.yarnpkg.com/@angular/flex-layout/-/flex-layout-15.0.0-beta.42.tgz#ad5e1dda32ee6280ba73765be10fd916c222e38e" - integrity sha512-cTAPVMMxnyIFwpZwdq0PL5mdP9Qh+R8MB7ZBezVaN3Rz2fRrkagzKpLvPX3TFzepXrvHBdpKsU4b8u+NxEC/6g== - dependencies: - tslib "^2.3.0" - "@angular/forms@18.2.13": version "18.2.13" resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-18.2.13.tgz#119f9b32b0da5e2f1bc3c07f506a645e5635f583" @@ -1938,6 +1931,59 @@ resolved "https://registry.yarnpkg.com/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.0.13.tgz#93bcd6dc24afd1cc60dd88a65b9e4fab32dcf397" integrity sha512-UCrMJQY/gJnOl3XgbWRZZUvGGBuKy6i0YNSptgMzHBjs+QYDYR1Mt/RLTOPy4fzzves65O1EDmlL//OzEqoLlA== +"@mapbox/geojson-rewind@^0.5.2": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz#591a5d71a9cd1da1a0bf3420b3bea31b0fc7946a" + integrity sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA== + dependencies: + get-stream "^6.0.1" + minimist "^1.2.6" + +"@mapbox/jsonlint-lines-primitives@^2.0.2", "@mapbox/jsonlint-lines-primitives@~2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz#ce56e539f83552b58d10d672ea4d6fc9adc7b234" + integrity sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ== + +"@mapbox/point-geometry@0.1.0", "@mapbox/point-geometry@^0.1.0", "@mapbox/point-geometry@~0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz#8a83f9335c7860effa2eeeca254332aa0aeed8f2" + integrity sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ== + +"@mapbox/tiny-sdf@^2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz#9a1d33e5018093e88f6a4df2343e886056287282" + integrity sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA== + +"@mapbox/unitbezier@^0.0.1": + version "0.0.1" + resolved "https://registry.yarnpkg.com/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz#d32deb66c7177e9e9dfc3bbd697083e2e657ff01" + integrity sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw== + +"@mapbox/vector-tile@^1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz#d3a74c90402d06e89ec66de49ec817ff53409666" + integrity sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw== + dependencies: + "@mapbox/point-geometry" "~0.1.0" + +"@mapbox/whoots-js@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz#497c67a1cef50d1a2459ba60f315e448d2ad87fe" + integrity sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q== + +"@maplibre/maplibre-gl-style-spec@^23.1.0": + version "23.1.0" + resolved "https://registry.yarnpkg.com/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-23.1.0.tgz#ad59731b0547ee0986ba4ccff699894dd60f0650" + integrity sha512-R6/ihEuC5KRexmKIYkWqUv84Gm+/QwsOUgHyt1yy2XqCdGdLvlBWVWIIeTZWN4NGdwmY6xDzdSGU2R9oBLNg2w== + dependencies: + "@mapbox/jsonlint-lines-primitives" "~2.0.2" + "@mapbox/unitbezier" "^0.0.1" + json-stringify-pretty-compact "^4.0.0" + minimist "^1.2.8" + quickselect "^3.0.0" + rw "^1.3.3" + tinyqueue "^3.0.0" + "@mat-datetimepicker/core@~14.0.0": version "14.0.0" resolved "https://registry.yarnpkg.com/@mat-datetimepicker/core/-/core-14.0.0.tgz#1776ae74c3ff94b2bf4e1c6c01c9366c8ab876b5" @@ -2663,6 +2709,13 @@ resolved "https://registry.yarnpkg.com/@types/flowjs/-/flowjs-2.13.14.tgz#62cdd8d5d8e0222e505e4a140e73cd385c0a496c" integrity sha512-OlZFH9hbOq5B+GeBjcM3UvvSailseRT+sKoi9jyFFczvvS1n1LTiTKJysDSLVWnUvGD+tsn7kxb6eC2uaJD+Zw== +"@types/geojson-vt@3.2.5": + version "3.2.5" + resolved "https://registry.yarnpkg.com/@types/geojson-vt/-/geojson-vt-3.2.5.tgz#b6c356874991d9ab4207533476dfbcdb21e38408" + integrity sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g== + dependencies: + "@types/geojson" "*" + "@types/geojson@*", "@types/geojson@^7946.0.10": version "7946.0.14" resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.14.tgz#319b63ad6df705ee2a65a73ef042c8271e696613" @@ -2673,6 +2726,11 @@ resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.8.tgz#30744afdb385e2945e22f3b033f897f76b1f12ca" integrity sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA== +"@types/geojson@^7946.0.16": + version "7946.0.16" + resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.16.tgz#8ebe53d69efada7044454e3305c19017d97ced2a" + integrity sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg== + "@types/hammerjs@^2.0.45": version "2.0.45" resolved "https://registry.yarnpkg.com/@types/hammerjs/-/hammerjs-2.0.45.tgz#ffa764bb68a66c08db6efb9c816eb7be850577b1" @@ -2752,6 +2810,20 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.13.tgz#786e2d67cfd95e32862143abe7463a7f90c300eb" integrity sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg== +"@types/mapbox__point-geometry@*", "@types/mapbox__point-geometry@^0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz#0ef017b75eedce02ff6243b4189210e2e6d5e56d" + integrity sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA== + +"@types/mapbox__vector-tile@^1.3.4": + version "1.3.4" + resolved "https://registry.yarnpkg.com/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz#ad757441ef1d34628d9e098afd9c91423c1f8734" + integrity sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg== + dependencies: + "@types/geojson" "*" + "@types/mapbox__point-geometry" "*" + "@types/pbf" "*" + "@types/mime@^1": version "1.3.5" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" @@ -2790,6 +2862,11 @@ dependencies: undici-types "~6.19.2" +"@types/pbf@*", "@types/pbf@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/pbf/-/pbf-3.0.5.tgz#a9495a58d8c75be4ffe9a0bd749a307715c07404" + integrity sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA== + "@types/qs@*": version "6.9.16" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.16.tgz#52bba125a07c0482d26747d5d4947a64daf8f794" @@ -2846,6 +2923,13 @@ dependencies: "@types/node" "*" +"@types/supercluster@^7.1.3": + version "7.1.3" + resolved "https://registry.yarnpkg.com/@types/supercluster/-/supercluster-7.1.3.tgz#1a1bc2401b09174d9c9e44124931ec7874a72b27" + integrity sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA== + dependencies: + "@types/geojson" "*" + "@types/systemjs@6.15.1": version "6.15.1" resolved "https://registry.yarnpkg.com/@types/systemjs/-/systemjs-6.15.1.tgz#dae1ec2fbe66af7c6ca1a110e2c9ca6b85135eec" @@ -4704,6 +4788,11 @@ domutils@^3.0.1: domelementtype "^2.3.0" domhandler "^5.0.3" +earcut@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/earcut/-/earcut-3.0.1.tgz#f60b3f671c5657cca9d3e131c5527c5dde00ef38" + integrity sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw== + eastasianwidth@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" @@ -5520,6 +5609,11 @@ geojson-rbush@3.x: "@types/geojson" "7946.0.8" rbush "^3.0.1" +geojson-vt@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/geojson-vt/-/geojson-vt-4.0.2.tgz#1162f6c7d61a0ba305b1030621e6e111f847828a" + integrity sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A== + get-caller-file@^2.0.1, get-caller-file@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" @@ -5541,7 +5635,7 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@ has-symbols "^1.0.3" hasown "^2.0.0" -get-stream@^6.0.0: +get-stream@^6.0.0, get-stream@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== @@ -5555,6 +5649,11 @@ get-symbol-description@^1.0.2: es-errors "^1.3.0" get-intrinsic "^1.2.4" +gl-matrix@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/gl-matrix/-/gl-matrix-3.4.3.tgz#fc1191e8320009fd4d20e9339595c6041ddc22c9" + integrity sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA== + glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" @@ -5598,6 +5697,15 @@ glob@^7.1.3: once "^1.3.0" path-is-absolute "^1.0.0" +global-prefix@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-4.0.0.tgz#e9cc79aab9be1d03287e156a3f912dd0895463ed" + integrity sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA== + dependencies: + ini "^4.1.3" + kind-of "^6.0.3" + which "^4.0.0" + globals@^11.1.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" @@ -5866,7 +5974,7 @@ icss-utils@^5.0.0, icss-utils@^5.1.0: resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== -ieee754@^1.1.13: +ieee754@^1.1.12, ieee754@^1.1.13: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== @@ -6395,6 +6503,11 @@ json-stable-stringify@^1.0.2: jsonify "^0.0.1" object-keys "^1.1.1" +json-stringify-pretty-compact@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz#cf4844770bddee3cb89a6170fe4b00eee5dbf1d4" + integrity sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q== + json5@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" @@ -6469,6 +6582,11 @@ katex@^0.16.0, katex@^0.16.9: dependencies: commander "^8.3.0" +kdbush@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/kdbush/-/kdbush-4.0.2.tgz#2f7b7246328b4657dd122b6c7f025fbc2c868e39" + integrity sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA== + keyv@^4.5.4: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" @@ -6481,7 +6599,7 @@ khroma@^2.1.0: resolved "https://registry.yarnpkg.com/khroma/-/khroma-2.1.0.tgz#45f2ce94ce231a437cf5b63c2e886e6eb42bbbb1" integrity sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw== -kind-of@^6.0.2: +kind-of@^6.0.2, kind-of@^6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== @@ -6549,10 +6667,10 @@ leaflet-rotatedmarker@^0.2.0: resolved "https://registry.yarnpkg.com/leaflet-rotatedmarker/-/leaflet-rotatedmarker-0.2.0.tgz#4467f49f98d1bfd56959bd9c6705203dd2601277" integrity sha512-yc97gxLXwbZa+Gk9VCcqI0CkvIBC9oNTTjFsHqq4EQvANrvaboib4UdeQLyTnEqDpaXHCqzwwVIDHtvz2mUiDg== -leaflet.gridlayer.googlemutant@0.14.1: - version "0.14.1" - resolved "https://registry.yarnpkg.com/leaflet.gridlayer.googlemutant/-/leaflet.gridlayer.googlemutant-0.14.1.tgz#c282209aa1a39eb2f87d8aaa4e9894181c9e20a0" - integrity sha512-/OYxEjmgxO1U1KOhTzg+m8c0b95J0943LU8DXQmdJu/x2f+1Ur78rvEPO2QCS0cmwZ3m6FvE5I3zXnBzJNWRCA== +leaflet.gridlayer.googlemutant@0.15.0: + version "0.15.0" + resolved "https://registry.yarnpkg.com/leaflet.gridlayer.googlemutant/-/leaflet.gridlayer.googlemutant-0.15.0.tgz#7a32d949578695b8aa8fa5fd11b40bd7a6ce6c23" + integrity sha512-kA5jCOBhCPigyue6YpZMhXMVYA1hM2pDIaJ6u0wSSiSZ2TQ4kZfKuhwkIexcadJfs0BP4FyhZIDZC7hmTlvjOA== leaflet.markercluster@1.5.3: version "1.5.3" @@ -6810,6 +6928,38 @@ make-plural@^7.0.0: resolved "https://registry.yarnpkg.com/make-plural/-/make-plural-7.4.0.tgz#fa6990dd550dea4de6b20163f74e5ed83d8a8d6d" integrity sha512-4/gC9KVNTV6pvYg2gFeQYTW3mWaoJt7WZE5vrp1KnQDgW92JtYZnzmZT81oj/dUTqAIu0ufI2x3dkgu3bB1tYg== +maplibre-gl@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-5.2.0.tgz#e3cdb66c82232cffbe149b032776484722caee4e" + integrity sha512-9zZKD0M80qtDsqBet+EDuAhoCeA/cnAuZAA0p3hcGKGbyjM/SH+R6wQvnBEgvJz9UhDynnkoKdUwhI+fUkHoXQ== + dependencies: + "@mapbox/geojson-rewind" "^0.5.2" + "@mapbox/jsonlint-lines-primitives" "^2.0.2" + "@mapbox/point-geometry" "^0.1.0" + "@mapbox/tiny-sdf" "^2.0.6" + "@mapbox/unitbezier" "^0.0.1" + "@mapbox/vector-tile" "^1.3.1" + "@mapbox/whoots-js" "^3.1.0" + "@maplibre/maplibre-gl-style-spec" "^23.1.0" + "@types/geojson" "^7946.0.16" + "@types/geojson-vt" "3.2.5" + "@types/mapbox__point-geometry" "^0.1.4" + "@types/mapbox__vector-tile" "^1.3.4" + "@types/pbf" "^3.0.5" + "@types/supercluster" "^7.1.3" + earcut "^3.0.1" + geojson-vt "^4.0.2" + gl-matrix "^3.4.3" + global-prefix "^4.0.0" + kdbush "^4.0.2" + murmurhash-js "^1.0.0" + pbf "^3.3.0" + potpack "^2.0.0" + quickselect "^3.0.0" + supercluster "^8.0.1" + tinyqueue "^3.0.0" + vt-pbf "^3.1.3" + marked@^13.0.2: version "13.0.3" resolved "https://registry.yarnpkg.com/marked/-/marked-13.0.3.tgz#5c5b4a5d0198060c7c9bc6ef9420a7fed30f822d" @@ -6954,7 +7104,7 @@ minimatch@^9.0.0, minimatch@^9.0.4: dependencies: brace-expansion "^2.0.1" -minimist@1.2.8, minimist@^1.2.0, minimist@^1.2.6: +minimist@1.2.8, minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -7104,6 +7254,11 @@ multicast-dns@^7.2.5: dns-packet "^5.2.2" thunky "^1.0.2" +murmurhash-js@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/murmurhash-js/-/murmurhash-js-1.0.0.tgz#b06278e21fc6c37fa5313732b0412bcb6ae15f51" + integrity sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw== + mute-stream@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-1.0.0.tgz#e31bd9fe62f0aed23520aa4324ea6671531e013e" @@ -7785,6 +7940,14 @@ pathe@^1.1.2: resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec" integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ== +pbf@^3.2.1, pbf@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/pbf/-/pbf-3.3.0.tgz#1790f3d99118333cc7f498de816028a346ef367f" + integrity sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q== + dependencies: + ieee754 "^1.1.12" + resolve-protobuf-schema "^2.1.0" + picocolors@^1.0.0, picocolors@^1.0.1, picocolors@^1.1.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" @@ -7978,6 +8141,11 @@ postinstall-prepare@^2.0.0: resolved "https://registry.yarnpkg.com/postinstall-prepare/-/postinstall-prepare-2.0.0.tgz#2a6867c1a13a05502aa115d0495efbbd778769cb" integrity sha512-lLFwEKdnGLAaRAm8OpXP6HwrXRW+b8Hh9vRhVHZKmCdobd+D21YM38BCDsi3zrePLSe8Tt0H/mbYkh7/ySQQMg== +potpack@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/potpack/-/potpack-2.0.0.tgz#61f4dd2dc4b3d5e996e3698c0ec9426d0e169104" + integrity sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw== + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -8016,6 +8184,11 @@ proto-list@~1.2.1: resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== +protocol-buffers-schema@^3.3.1: + version "3.6.0" + resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz#77bc75a48b2ff142c1ad5b5b90c94cd0fa2efd03" + integrity sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw== + proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -8060,6 +8233,11 @@ quickselect@^2.0.0: resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-2.0.0.tgz#f19680a486a5eefb581303e023e98faaf25dd018" integrity sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw== +quickselect@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-3.0.0.tgz#a37fc953867d56f095a20ac71c6d27063d2de603" + integrity sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g== + randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -8235,6 +8413,13 @@ resolve-from@^4.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== +resolve-protobuf-schema@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz#9ca9a9e69cf192bbdaf1006ec1973948aa4a3758" + integrity sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ== + dependencies: + protocol-buffers-schema "^3.3.1" + resolve-url-loader@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz#ee3142fb1f1e0d9db9524d539cfa166e9314f795" @@ -8357,7 +8542,7 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -rw@1: +rw@1, rw@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4" integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ== @@ -8964,6 +9149,13 @@ sucrase@^3.35.0: pirates "^4.0.1" ts-interface-checker "^0.1.9" +supercluster@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/supercluster/-/supercluster-8.0.1.tgz#9946ba123538e9e9ab15de472531f604e7372df5" + integrity sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ== + dependencies: + kdbush "^4.0.2" + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -9145,6 +9337,11 @@ tinymce@6.8.5, "tinymce@^7.0.0 || ^6.0.0 || ^5.5.0", tinymce@~6.8.5: resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-6.8.5.tgz#aa9a711c4e0b59d506dd281bade857d35a7b3c59" integrity sha512-qAL/FxL7cwZHj4BfaF818zeJJizK9jU5IQzTcSLL4Rj5MaJdiVblEj7aDr80VCV1w9h4Lak9hlnALhq/kVtN1g== +tinyqueue@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-3.0.0.tgz#101ea761ccc81f979e29200929e78f1556e3661e" + integrity sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g== + tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" @@ -9537,6 +9734,15 @@ vscode-uri@~3.0.8: resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f" integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw== +vt-pbf@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/vt-pbf/-/vt-pbf-3.1.3.tgz#68fd150756465e2edae1cc5c048e063916dcfaac" + integrity sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA== + dependencies: + "@mapbox/point-geometry" "0.1.0" + "@mapbox/vector-tile" "^1.3.1" + pbf "^3.2.1" + watchpack@2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.1.tgz#29308f2cac150fa8e4c92f90e0ec954a9fed7fff"