Browse Source

Merge pull request #12875 from thingsboard/feature/maps_v3

New Map widgets
pull/12883/head
Igor Kulikov 2 years ago
committed by GitHub
parent
commit
f031a357ae
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 8
      application/src/main/data/json/system/widget_bundles/maps.json
  2. 4
      application/src/main/data/json/system/widget_types/google_map.json
  3. 4
      application/src/main/data/json/system/widget_types/here_map.json
  4. 95
      application/src/main/data/json/system/widget_types/image_map.json
  5. 102
      application/src/main/data/json/system/widget_types/image_map_deprecated.json
  6. 44
      application/src/main/data/json/system/widget_types/map.json
  7. 4
      application/src/main/data/json/system/widget_types/markers_placement___google_maps.json
  8. 4
      application/src/main/data/json/system/widget_types/markers_placement___image_map.json
  9. 4
      application/src/main/data/json/system/widget_types/markers_placement___openstreetmap.json
  10. 4
      application/src/main/data/json/system/widget_types/openstreet_map.json
  11. 80
      application/src/main/data/json/system/widget_types/route_map.json
  12. 2
      application/src/main/data/json/system/widget_types/route_map___google.json
  13. 2
      application/src/main/data/json/system/widget_types/route_map___openstreet.json
  14. 2
      application/src/main/data/json/system/widget_types/route_map___tencent.json
  15. 4
      application/src/main/data/json/system/widget_types/tencent_map.json
  16. 2
      application/src/main/data/json/system/widget_types/trip_animation.json
  17. 47
      application/src/main/data/json/system/widget_types/trip_map.json
  18. 1
      dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java
  19. 3
      ui-ngx/angular.json
  20. 223
      ui-ngx/patches/tooltipster+4.2.8.patch
  21. 10
      ui-ngx/src/app/core/api/widget-api.models.ts
  22. 154
      ui-ngx/src/app/core/services/item-buffer.service.ts
  23. 46
      ui-ngx/src/app/core/utils.ts
  24. 20
      ui-ngx/src/app/modules/common/modules-map.ts
  25. 3
      ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.ts
  26. 3
      ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html
  27. 2
      ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts
  28. 2
      ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.ts
  29. 1
      ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.html
  30. 3
      ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.ts
  31. 7
      ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts
  32. 3
      ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html
  33. 2
      ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts
  34. 7
      ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts
  35. 4
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts
  36. 2
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts
  37. 2
      ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-key-row.component.ts
  38. 6
      ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts
  39. 2
      ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.ts
  40. 3
      ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts
  41. 100
      ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html
  42. 187
      ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.ts
  43. 8
      ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html
  44. 6
      ui-ngx/src/app/modules/home/components/widget/config/datasource.component.models.ts
  45. 6
      ui-ngx/src/app/modules/home/components/widget/config/datasource.component.ts
  46. 2
      ui-ngx/src/app/modules/home/components/widget/config/datasources.component.ts
  47. 2
      ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts
  48. 20
      ui-ngx/src/app/modules/home/components/widget/config/widget-config-components.module.ts
  49. 10
      ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts
  50. 2
      ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts
  51. 8
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/circle.ts
  52. 0
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/common-maps-utils.ts
  53. 0
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.html
  54. 0
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.scss
  55. 0
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.ts
  56. 14
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts
  57. 0
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-models.ts
  58. 2
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-widget.interface.ts
  59. 2
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-widget2.ts
  60. 2
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/maps-utils.ts
  61. 0
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.scss
  62. 4
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.ts
  63. 2
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/polygon.ts
  64. 2
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/polyline.ts
  65. 0
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/google-map.ts
  66. 0
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/here-map.ts
  67. 17
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/image-map.ts
  68. 0
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/openstreet-map.ts
  69. 2
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/public-api.ts
  70. 0
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/tencent-map.ts
  71. 237
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts
  72. 96
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts
  73. 439
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/latest-map-data-layer.ts
  74. 265
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts
  75. 738
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts
  76. 435
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts
  77. 54
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts
  78. 596
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts
  79. 183
      ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts
  80. 342
      ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts
  81. 674
      ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts
  82. 239
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts
  83. 25
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.html
  84. 37
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.scss
  85. 95
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts
  86. 54
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts
  87. 425
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss
  88. 1209
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts
  89. 77
      ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html
  90. 57
      ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss
  91. 269
      ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts
  92. 44
      ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.html
  93. 38
      ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.scss
  94. 69
      ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts
  95. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.ts
  96. 1
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/action-settings-button.component.html
  97. 7
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html
  98. 39
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.ts
  99. 14
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings.component.ts
  100. 33
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html

8
application/src/main/data/json/system/widget_bundles/maps.json

File diff suppressed because one or more lines are too long

4
application/src/main/data/json/system/widget_types/google_map.json

File diff suppressed because one or more lines are too long

4
application/src/main/data/json/system/widget_types/here_map.json

File diff suppressed because one or more lines are too long

95
application/src/main/data/json/system/widget_types/image_map.json

File diff suppressed because one or more lines are too long

102
application/src/main/data/json/system/widget_types/image_map_deprecated.json

File diff suppressed because one or more lines are too long

44
application/src/main/data/json/system/widget_types/map.json

File diff suppressed because one or more lines are too long

4
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\":\"<b>${entityName}</b><br/><br/><b>Latitude:</b> ${latitude:7}<br/><b>Longitude:</b> ${longitude:7}<br/><br/><link-act name='delete'>Delete</link-act>\",\"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\":\"<b>${entityName}</b><br/><br/><b>TimeStamp:</b> ${coordinates|ts:7}<br/><br/><link-act name='delete_polygon'>Delete</link-act>\",\"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": [

4
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\":\"<b>${entityName}</b><br/><br/><b>X Pos:</b> ${xPos:2}<br/><b>Y Pos:</b> ${yPos:2}<br/><br/><link-act name='delete'>Delete</link-act>\",\"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\":\"<b>${entityName}</b><br/><br/><b>TimeStamp:</b> ${coordinates|ts:7}<br/><br/><link-act name='delete_polygon'>Delete</link-act>\"},\"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": [

4
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\":\"<b>${entityName}</b><br/><br/><b>Latitude:</b> ${latitude:7}<br/><b>Longitude:</b> ${longitude:7}<br/><br/><link-act name='delete'>Delete</link-act>\",\"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\":\"<b>${entityName}</b><br/><br/><b>TimeStamp:</b> ${coordinates|ts:7}<br/><br/><link-act name='delete'>Delete</link-act>\"},\"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": [

4
application/src/main/data/json/system/widget_types/openstreet_map.json

File diff suppressed because one or more lines are too long

80
application/src/main/data/json/system/widget_types/route_map.json

File diff suppressed because one or more lines are too long

2
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": {

2
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": {

2
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": {

4
application/src/main/data/json/system/widget_types/tencent_map.json

File diff suppressed because one or more lines are too long

2
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": {

47
application/src/main/data/json/system/widget_types/trip_map.json

File diff suppressed because one or more lines are too long

1
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);
}
}

3
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",

223
ui-ngx/patches/tooltipster+4.2.8.patch

File diff suppressed because one or more lines are too long

10
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<WidgetActionDescriptor>};
getActionDescriptors: (actionSourceId: string) => Array<WidgetActionDescriptor>;
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 {

154
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<Dashboard> {
breakpoint = 'default',
widgetExportInfo?: any): Observable<Dashboard> {
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) {

46
ui-ngx/src/app/core/utils.ts

@ -17,7 +17,7 @@
import _ from 'lodash';
import { from, 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';
@ -491,11 +491,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<D extends Datasource = Datasource>(input: DatasourceData[], dataIndex?: number, ts?: number,
groupFunction: (el: DatasourceData) => any = (el) => el.datasource.entityName + el.datasource.entityType): FormattedData<D>[] {
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<D>(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 +512,20 @@ export function formattedDataFormDatasourceData(input: DatasourceData[], dataInd
});
}
export function formattedDataArrayFromDatasourceData(input: DatasourceData[]): FormattedData[][] {
return _(input).groupBy(el => el.datasource.entityName)
export function formattedDataArrayFromDatasourceData<D extends Datasource = Datasource>(input: DatasourceData[],
groupFunction: (el: DatasourceData) => any =
(el) => el.datasource.entityName + el.datasource.entityType): FormattedData<D>[][] {
return _(input).groupBy(groupFunction)
.values().value().map((entityArray, dsIndex) => {
const timeDataMap: {[time: number]: FormattedData} = {};
const timeDataMap: {[time: number]: FormattedData<D>} = {};
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<D>(datasource, dsIndex);
data.time = time;
timeDataMap[time] = data;
}
@ -537,7 +540,7 @@ export function formattedDataArrayFromDatasourceData(input: DatasourceData[]): F
});
}
export function formattedDataFromDatasource(datasource: Datasource, dsIndex: number): FormattedData {
export function formattedDataFromDatasource<D extends Datasource = Datasource>(datasource: D, dsIndex: number): FormattedData<D> {
return {
entityName: datasource.entityName,
deviceName: datasource.entityName,
@ -845,7 +848,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 +882,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;

20
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';
@ -577,10 +577,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 +610,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,

3
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() {

3
ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html

@ -83,8 +83,7 @@
<tb-entity-autocomplete
class="flex flex-1"
formControlName="id"
[hideLabel]="true"
[inlineField]="true"
inlineField
[placeholder]="'action.set' | translate"
[required]="true"
[entityType]="ArgumentEntityTypeParamsMap.get(entityType).entityType"

2
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;

2
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({

1
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">
</tb-manage-widget-actions>
</div>

3
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({

7
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) => {

3
ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html

@ -121,7 +121,8 @@
<tb-widget-action
formControlName="widgetAction"
[callbacks]="data.callbacks"
[widgetType]="data.widgetType">
[widgetType]="data.widgetType"
[additionalWidgetActionTypes]="data.additionalWidgetActionTypes">
</tb-widget-action>
</div>
</fieldset>

2
ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts

@ -43,6 +43,7 @@ import {
CellClickColumnInfo,
defaultWidgetAction,
WidgetActionSource,
WidgetActionType,
widgetType
} from '@shared/models/widget.models';
import { takeUntil } from 'rxjs/operators';
@ -58,6 +59,7 @@ export interface WidgetActionDialogData {
actionsData: WidgetActionsData;
action?: WidgetActionDescriptorInfo;
widgetType: widgetType;
additionalWidgetActionTypes?: WidgetActionType[];
}
@Component({

7
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 {

4
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,

2
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';

2
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,

6
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,

2
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';

3
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) => {

100
ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html

@ -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.
-->
<ng-container [formGroup]="mapWidgetConfigForm">
<tb-timewindow-config-panel *ngIf="trip" formControlName="timewindowConfig">
</tb-timewindow-config-panel>
<tb-map-settings
[trip]="trip"
[functionsOnly]="functionsOnly"
[aliasController]="aliasController"
[callbacks]="callbacks"
[widget]="widget"
formControlName="mapSettings">
</tb-map-settings>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.appearance</div>
<div class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTitle">
{{ 'widget-config.title' | translate }}
</mat-slide-toggle>
<div class="flex flex-1 flex-row items-center justify-start gap-2">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="title" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-font-settings formControlName="titleFont"
clearButton
[previewText]="mapWidgetConfigForm.get('title').value"
[initialPreviewStyle]="widgetConfig.config.titleStyle">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="titleColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon">
{{ 'widget-config.card-icon' | translate }}
</mat-slide-toggle>
<div class="flex flex-1 flex-row items-center justify-start gap-2">
<mat-form-field appearance="outline" class="number flex" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="iconSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-css-unit-select class="flex-1" formControlName="iconSizeUnit"></tb-css-unit-select>
<tb-material-icon-select asBoxInput
iconClearButton
[color]="mapWidgetConfigForm.get('iconColor').value"
formControlName="icon">
</tb-material-icon-select>
<tb-color-input asBoxInput
colorClearButton
formControlName="iconColor">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.card-appearance</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.background.background' | translate }}</div>
<tb-background-settings formControlName="background">
</tb-background-settings>
</div>
<div class="tb-form-row space-between column-lt-md">
<div translate>widget-config.show-card-buttons</div>
<mat-chip-listbox multiple formControlName="cardButtons">
<mat-chip-option value="fullscreen">{{ 'fullscreen.fullscreen' | translate }}</mat-chip-option>
</mat-chip-listbox>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widget-config.card-border-radius' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="borderRadius" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widget-config.card-padding' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="padding" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
<tb-widget-actions-panel
formControlName="actions">
</tb-widget-actions-panel>
</ng-container>

187
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<AppState>,
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<MapWidgetSettings>({} 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');
}
}

8
ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html

@ -66,6 +66,10 @@
<tb-data-keys class="tb-data-keys flex-1"
[widgetType]="widgetType"
[datasourceType]="datasourceFormGroup.get('type').value"
[hideDataKeyLabel]="hideDataKeyLabel"
[hideDataKeyColor]="hideDataKeyColor"
[hideDataKeyUnits]="hideDataKeyUnits"
[hideDataKeyDecimals]="hideDataKeyDecimals"
[maxDataKeys]="maxDataKeys"
[optDataKeys]="isDataKeysOptional(datasourceFormGroup.get('type').value)"
[simpleDataKeysLabel]="!hasAdditionalLatestDataKeys"
@ -83,6 +87,10 @@
<tb-data-keys *ngIf="hasAdditionalLatestDataKeys" class="tb-data-keys flex-1"
[widgetType]="widgetTypes.latest"
[datasourceType]="datasourceFormGroup.get('type').value"
[hideDataKeyLabel]="hideDataKeyLabel"
[hideDataKeyColor]="hideDataKeyColor"
[hideDataKeyUnits]="hideDataKeyUnits"
[hideDataKeyDecimals]="hideDataKeyDecimals"
latestDataKeys
[optDataKeys]="true"
[aliasController]="aliasController"

6
ui-ngx/src/app/modules/home/components/widget/config/datasource.component.models.ts

@ -14,8 +14,8 @@
/// limitations under the License.
///
import { EntityAliasSelectCallbacks } from '@home/components/alias/entity-alias-select.component.models';
import { FilterSelectCallbacks } from '@home/components/filter/filter-select.component.models';
import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models';
import { EntityAliasSelectCallbacks } from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component.models';
import { FilterSelectCallbacks } from '@home/components/widget/lib/settings/common/filter/filter-select.component.models';
import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models';
export type DatasourceCallbacks = EntityAliasSelectCallbacks & FilterSelectCallbacks & DataKeysCallbacks;

6
ui-ngx/src/app/modules/home/components/widget/config/datasource.component.ts

@ -37,9 +37,9 @@ import { AlarmSearchStatus } from '@shared/models/alarm.models';
import { Dashboard } from '@shared/models/dashboard.models';
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component';
import { IAliasController } from '@core/api/widget-api.models';
import { EntityAliasSelectCallbacks } from '@home/components/alias/entity-alias-select.component.models';
import { FilterSelectCallbacks } from '@home/components/filter/filter-select.component.models';
import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models';
import { EntityAliasSelectCallbacks } from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component.models';
import { FilterSelectCallbacks } from '@home/components/widget/lib/settings/common/filter/filter-select.component.models';
import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/lib/settings/common/key/data-keys.component.models';
import { EntityType } from '@shared/models/entity-type.models';
import { DatasourcesComponent } from '@home/components/widget/config/datasources.component';
import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models';

2
ui-ngx/src/app/modules/home/components/widget/config/datasources.component.ts

@ -39,7 +39,7 @@ import { CdkDragDrop } from '@angular/cdk/drag-drop';
import { deepClone } from '@core/utils';
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 { TranslateService } from '@ngx-translate/core';
import { coerceBoolean } from '@shared/decorators/coercion';
import { FormProperty } from '@shared/models/dynamic-form.models';

2
ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts

@ -31,7 +31,7 @@ import { WidgetConfigComponent } from '@home/components/widget/widget-config.com
import { TargetDevice, TargetDeviceType } from '@shared/models/widget.models';
import { EntityType } from '@shared/models/entity-type.models';
import { IAliasController } from '@core/api/widget-api.models';
import { EntityAliasSelectCallbacks } from '@home/components/alias/entity-alias-select.component.models';
import { EntityAliasSelectCallbacks } from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({

20
ui-ngx/src/app/modules/home/components/widget/config/widget-config-components.module.ts

@ -19,15 +19,9 @@ import { CommonModule } from '@angular/common';
import { SharedModule } from '@app/shared/shared.module';
import { AlarmFilterConfigComponent } from '@home/components/alarm/alarm-filter-config.component';
import { AlarmAssigneeSelectComponent } from '@home/components/alarm/alarm-assignee-select.component';
import { DataKeysComponent } from '@home/components/widget/config/data-keys.component';
import { DataKeyConfigDialogComponent } from '@home/components/widget/config/data-key-config-dialog.component';
import { DataKeyConfigComponent } from '@home/components/widget/config/data-key-config.component';
import { DatasourceComponent } from '@home/components/widget/config/datasource.component';
import { DatasourcesComponent } from '@home/components/widget/config/datasources.component';
import { EntityAliasSelectComponent } from '@home/components/alias/entity-alias-select.component';
import { FilterSelectComponent } from '@home/components/filter/filter-select.component';
import { WidgetSettingsModule } from '@home/components/widget/lib/settings/widget-settings.module';
import { WidgetSettingsComponent } from '@home/components/widget/config/widget-settings.component';
import { TimewindowConfigPanelComponent } from '@home/components/widget/config/timewindow-config-panel.component';
import { WidgetSettingsCommonModule } from '@home/components/widget/lib/settings/common/widget-settings-common.module';
import { TimewindowStyleComponent } from '@home/components/widget/config/timewindow-style.component';
@ -39,18 +33,12 @@ import { TargetDeviceComponent } from '@home/components/widget/config/target-dev
[
AlarmAssigneeSelectComponent,
AlarmFilterConfigComponent,
DataKeysComponent,
DataKeyConfigDialogComponent,
DataKeyConfigComponent,
DatasourceComponent,
DatasourcesComponent,
TargetDeviceComponent,
EntityAliasSelectComponent,
FilterSelectComponent,
TimewindowStyleComponent,
TimewindowStylePanelComponent,
TimewindowConfigPanelComponent,
WidgetSettingsComponent
TimewindowConfigPanelComponent
],
imports: [
CommonModule,
@ -61,18 +49,12 @@ import { TargetDeviceComponent } from '@home/components/widget/config/target-dev
exports: [
AlarmAssigneeSelectComponent,
AlarmFilterConfigComponent,
DataKeysComponent,
DataKeyConfigDialogComponent,
DataKeyConfigComponent,
DatasourceComponent,
DatasourcesComponent,
TargetDeviceComponent,
EntityAliasSelectComponent,
FilterSelectComponent,
TimewindowStyleComponent,
TimewindowStylePanelComponent,
TimewindowConfigPanelComponent,
WidgetSettingsComponent,
WidgetSettingsCommonModule
]
})

10
ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts

@ -23,7 +23,7 @@ import { PageComponent } from '@shared/components/page.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { AbstractControl, UntypedFormGroup } from '@angular/forms';
import { DataKey, DatasourceType, WidgetConfigMode, widgetType } from '@shared/models/widget.models';
import { DataKey, DatasourceType, Widget, WidgetConfigMode, widgetType } from '@shared/models/widget.models';
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component';
import { isDefinedAndNotNull } from '@core/utils';
import { IAliasController } from '@core/api/widget-api.models';
@ -67,6 +67,10 @@ export abstract class BasicWidgetConfigComponent extends PageComponent implement
return this.widgetConfigComponent.widgetConfigCallbacks;
}
get functionsOnly(): boolean {
return this.widgetConfigComponent.functionsOnly;
}
get widgetType(): widgetType {
return this.widgetConfigComponent.widgetType;
}
@ -75,6 +79,10 @@ export abstract class BasicWidgetConfigComponent extends PageComponent implement
return this.widgetConfigComponent.widgetEditMode;
}
get widget(): Widget {
return this.widgetConfigComponent.widget;
}
widgetConfigChangedEmitter = new EventEmitter<WidgetConfigComponentData>();
widgetConfigChanged = this.widgetConfigChangedEmitter.asObservable();

2
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';

8
ui-ngx/src/app/modules/home/components/widget/lib/maps/circle.ts → 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';

0
ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts → ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/common-maps-utils.ts

0
ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html → ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.html

0
ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.scss → ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.scss

0
ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts → ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.ts

14
ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts → 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,

0
ui-ngx/src/app/modules/home/components/widget/lib/maps/map-models.ts → ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-models.ts

2
ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.interface.ts → 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;

2
ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget2.ts → 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';

2
ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts → 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,

0
ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.scss → ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.scss

4
ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.ts → 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) {

2
ui-ngx/src/app/modules/home/components/widget/lib/maps/polygon.ts → 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 {

2
ui-ngx/src/app/modules/home/components/widget/lib/maps/polyline.ts → 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 {

0
ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/google-map.ts → ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/google-map.ts

0
ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/here-map.ts → ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/here-map.ts

17
ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/image-map.ts → 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<MapImage> {
private imageFromUrl(url: string, update = false): Observable<MapImage> {
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<MapImage> {
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))
);
})
);

0
ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/openstreet-map.ts → ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/openstreet-map.ts

2
ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/public-api.ts → 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<LeafletMap> } = {
'openstreet-map': OpenStreetMap,

0
ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/tencent-map.ts → ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/tencent-map.ts

237
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<CirclesDataLayerSettings, TbCirclesDataLayer> {
private circle: L.Circle;
private circleStyle: L.PathOptions;
private editing = false;
constructor(data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[],
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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L.Layer {
const circleData = this.dataLayer.extractCircleCoordinates(data);
const center = new L.LatLng(circleData.latitude, circleData.longitude);
this.circleStyle = this.dataLayer.getShapeStyle(data, dsData);
this.circle = L.circle(center, {
bubblingMouseEvents: !this.dataLayer.isEditMode(),
radius: circleData.radius,
...this.circleStyle,
snapIgnore: !this.dataLayer.isSnappable()
});
this.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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void {
this.circleStyle = this.dataLayer.getShapeStyle(data, dsData);
this.updateCircleShape(data);
this.updateTooltip(data, dsData);
this.updateLabel(data, dsData);
this.circle.setStyle(this.circleStyle);
}
protected doInvalidateCoordinates(data: FormattedData<TbMapDatasource>, _dsData: FormattedData<TbMapDatasource>[]): 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<any> {
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<TbMapDatasource>) {
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<CirclesDataLayerSettings, TbCirclesDataLayer> {
constructor(protected map: TbMap<any>,
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<TbMapDatasource>) {
const circleData: TbCircleData = JSON.parse(data[this.settings.circleKey.label]);
return this.map.circleDataToCoordinates(circleData);
}
public saveCircleCoordinates(data: FormattedData<TbMapDatasource>, center: L.LatLng, radius: number): Observable<TbCircleData> {
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<any>): Partial<CirclesDataLayerSettings> {
return defaultBaseCirclesDataLayerSettings(map.type());
}
protected doSetup(): Observable<void> {
return super.doSetup();
}
protected isValidLayerData(layerData: FormattedData<TbMapDatasource>): boolean {
return layerData && isNotEmptyStr(layerData[this.settings.circleKey.label]) && isJSON(layerData[this.settings.circleKey.label]);
}
protected createLayerItem(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): TbLatestDataLayerItem<CirclesDataLayerSettings, TbCirclesDataLayer> {
return new TbCircleDataLayerItem(data, dsData, this.settings, this);
}
}

96
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<any>,
layer: L.Layer,
settings: DataLayerTooltipSettings,
data: FormattedData<TbMapDatasource>,
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<any>,
tooltip: L.Popup,
settings: DataLayerTooltipSettings,
processor: DataLayerPatternProcessor,
data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[]): 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<any>, tooltip: L.Popup, settings: DataLayerTooltipSettings, data: FormattedData<TbMapDatasource>): 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;
};
}
}
}
);
}

439
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<S extends MapDataLayerSettings = MapDataLayerSettings,
D extends TbLatestMapDataLayer<S,D> = TbLatestMapDataLayer<any>, L extends L.Layer = L.Layer> extends TbDataLayerItem<S,D,L> {
protected tooltip: L.Popup;
protected data: FormattedData<TbMapDatasource>;
protected selected = false;
protected constructor(data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[],
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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) {
if (this.settings.tooltip.show) {
updateTooltip(this.dataLayer.getMap(), this.tooltip,
this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, data, dsData);
}
}
protected updateLabel(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) {
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 = `<div style="color: ${labelColor};"><b>${label}</b></div>`;
this.bindLabel(content);
}
}
protected canDeselect(cancel = false): boolean {
return true;
}
protected onSelected(): L.TB.ToolbarButtonOptions[] {
return [];
}
protected onDeselected(): void {}
protected abstract create(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L;
protected abstract doUpdate(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void;
protected abstract doInvalidateCoordinates(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<any>;
}
export interface UnplacedMapDataItem {
entity: FormattedData<TbMapDatasource>;
dataLayer: TbLatestMapDataLayer;
}
export abstract class TbLatestMapDataLayer<S extends MapDataLayerSettings = MapDataLayerSettings,
D extends TbLatestMapDataLayer<S,D> = any, L extends L.Layer = L.Layer> extends TbMapDataLayer<S, TbLatestDataLayerItem<S,D,L>> 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<any>,
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<TbMapDatasource>[]) {
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<S,D,L>[] = [];
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<S,D,L>[]): 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<TbMapDatasource>): boolean;
protected abstract createLayerItem(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): TbLatestDataLayerItem<S,D,L>;
}

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

@ -0,0 +1,265 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT 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,
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 { 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';
export class DataLayerPatternProcessor {
private patternFunction: CompiledTbFunction<MapStringFunction>;
private pattern: string;
constructor(private dataLayer: TbMapDataLayer,
private settings: DataLayerPatternSettings) {}
public setup(): Observable<void> {
if (this.settings.type === DataLayerPatternType.function) {
return parseTbFunction<MapStringFunction>(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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<MapStringFunction>;
private color: string;
constructor(private dataLayer: TbMapDataLayer,
private settings: DataLayerColorSettings) {}
public setup(): Observable<void> {
this.color = this.settings.color;
if (this.settings.type === DataLayerColorType.function) {
return parseTbFunction<MapStringFunction>(this.dataLayer.getCtx().http, this.settings.colorFunction, ['data', 'dsData']).pipe(
map((parsed) => {
this.colorFunction = parsed;
return null;
})
);
} else {
return of(null)
}
}
public processColor(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): string {
let color: string;
if (this.settings.type === DataLayerColorType.function) {
color = safeExecuteTbFunction(this.colorFunction, [data, dsData]);
if (!color) {
color = this.color;
}
} else {
color = this.color;
}
return color;
}
}
export abstract class TbDataLayerItem<S extends MapDataLayerSettings = MapDataLayerSettings, D extends TbMapDataLayer = TbMapDataLayer, L extends L.Layer = L.Layer> {
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<S extends MapDataLayerSettings = MapDataLayerSettings, I extends TbDataLayerItem = any> {
protected settings: S;
protected datasource: TbMapDatasource;
protected mapDataId = guid();
protected dataLayerContainer: L.FeatureGroup;
protected layerItems = new Map<string, I>();
protected groupsState: {[group: string]: boolean} = {};
protected enabled = true;
protected snappable = false;
public dataLayerLabelProcessor: DataLayerPatternProcessor;
public dataLayerTooltipProcessor: DataLayerPatternProcessor;
protected constructor(protected map: TbMap<any>,
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<any> {
this.datasource = mapDataSourceSettingsToDatasource(this.settings);
this.datasource.dataKeys = this.settings.additionalDataKeys ? [...this.settings.additionalDataKeys] : [];
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<any> {
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;
}
protected createDataLayerContainer(): L.FeatureGroup {
return L.featureGroup([], {snapIgnore: true});
}
protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource {
return datasource;
}
protected onDataLayerEnabled(): void {}
protected onDataLayerDisabled(): void {}
public abstract dataLayerType(): MapDataLayerType;
protected abstract defaultBaseSettings(map: TbMap<any>): Partial<S>;
protected abstract doSetup(): Observable<any>;
}

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

@ -0,0 +1,738 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT 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,
DataLayerColorType,
defaultBaseMarkersDataLayerSettings,
isValidLatLng,
loadImageWithAspect, MapDataLayerType,
MapStringFunction,
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 { TbMapDataLayer } from '@home/components/widget/lib/maps/data-layer/map-data-layer';
export class MarkerDataProcessor<S extends MarkersDataLayerSettings = MarkersDataLayerSettings> {
private positionFunction: CompiledTbFunction<MarkerPositionFunction>;
private markerIconProcessor: MarkerIconProcessor<any>;
constructor(public dataLayer: TbMapDataLayer,
private settings: S,
public markerOffset: L.LatLngTuple,
public tooltipOffset: L.LatLngTuple) {
}
public setup(): Observable<any> {
this.markerIconProcessor = MarkerIconProcessor.fromSettings(this, this.settings);
const setup$: Observable<void>[] = [this.markerIconProcessor.setup()];
if (this.dataLayer.mapType() === MapType.image) {
setup$.push(
parseTbFunction<MarkerPositionFunction>(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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<TbMapDatasource>): {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<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[],
rotationAngle?: number): Observable<MarkerIconInfo> {
return this.markerIconProcessor.createMarkerIcon(data, dsData, rotationAngle);
}
public createDefaultMarkerIcon(rotationAngle = 0): Observable<MarkerIconInfo> {
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<MarkerIconInfo> {
return createColorMarkerShapeURI(this.dataLayer.getCtx().$injector.get(MatIconRegistry), this.dataLayer.getCtx().$injector.get(DomSanitizer), shape, color).pipe(
map((iconUrl) => {
let icon: L.Icon<L.BaseIconOptions>;
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: `<div style="${style}"></div>`,
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<MarkerIconInfo> {
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<S> {
static fromSettings(dataProcessor: MarkerDataProcessor,
settings: MarkersDataLayerSettings): MarkerIconProcessor<any> {
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<void>;
public abstract createMarkerIcon(data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[],
rotationAngle?: number): Observable<MarkerIconInfo>;
}
abstract class BaseColorMarkerShapeProcessor<S extends BaseMarkerShapeSettings> extends MarkerIconProcessor<S> {
private markerColorFunction: CompiledTbFunction<MapStringFunction>;
private defaultMarkerIconInfo: MarkerIconInfo;
protected constructor(protected dataProcessor: MarkerDataProcessor,
protected settings: S) {
super(dataProcessor, settings);
}
public setup(): Observable<void> {
const colorSettings = this.settings.color;
if (colorSettings.type === DataLayerColorType.function) {
return parseTbFunction<MapStringFunction>(this.dataProcessor.dataLayer.getCtx().http, colorSettings.colorFunction, ['data', 'dsData']).pipe(
map((parsed) => {
this.markerColorFunction = parsed;
return null;
})
);
} else {
const color = tinycolor(colorSettings.color);
return this.createMarkerShape(color, 0, this.settings.size).pipe(
map((info) => {
this.defaultMarkerIconInfo = info;
return null;
}
));
}
}
public createMarkerIcon(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[], rotationAngle = 0): Observable<MarkerIconInfo> {
const colorSettings = this.settings.color;
let color: tinycolor.Instance;
if (colorSettings.type === DataLayerColorType.function) {
const functionColor = safeExecuteTbFunction(this.markerColorFunction, [data, dsData]);
if (isDefinedAndNotNull(functionColor)) {
color = tinycolor(functionColor);
} else {
color = tinycolor(colorSettings.color);
}
return this.createMarkerShape(color, rotationAngle, this.settings.size);
} else if (rotationAngle === 0) {
return of(this.defaultMarkerIconInfo);
} else {
color = tinycolor(colorSettings.color);
return this.createMarkerShape(color, rotationAngle, this.settings.size);
}
}
protected abstract createMarkerShape(color: tinycolor.Instance, rotationAngle: number, size: number): Observable<MarkerIconInfo>;
}
class ShapeMarkerIconProcessor extends BaseColorMarkerShapeProcessor<MarkerShapeSettings> {
constructor(protected dataProcessor: MarkerDataProcessor,
protected settings: MarkerShapeSettings) {
super(dataProcessor, settings);
}
protected createMarkerShape(color: tinycolor.Instance, rotationAngle: number, size: number): Observable<MarkerIconInfo> {
return this.dataProcessor.createColoredMarkerShape(this.settings.shape, color, rotationAngle, size);
}
}
class IconMarkerIconProcessor extends BaseColorMarkerShapeProcessor<MarkerIconSettings> {
constructor(protected dataProcessor: MarkerDataProcessor,
protected settings: MarkerIconSettings) {
super(dataProcessor, settings);
}
protected createMarkerShape(color: tinycolor.Instance, rotationAngle: number, size: number): Observable<MarkerIconInfo> {
return this.dataProcessor.createColoredMarkerIcon(this.settings.iconContainer, this.settings.icon, color, rotationAngle, size);
}
}
class ImageMarkerIconProcessor extends MarkerIconProcessor<MarkerImageSettings> {
private markerImageFunction: CompiledTbFunction<MarkerImageFunction>;
private defaultMarkerIconInfo: MarkerIconInfo;
constructor(protected dataProcessor: MarkerDataProcessor,
protected settings: MarkerImageSettings) {
super(dataProcessor, settings);
}
public setup(): Observable<void> {
if (this.settings.type === MarkerImageType.function) {
return parseTbFunction<MarkerImageFunction>(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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[], rotationAngle = 0): Observable<MarkerIconInfo> {
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<MarkerIconInfo> {
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<L.BaseIconOptions>;
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: `<div style="${style}"></div>`,
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<MarkersDataLayerSettings, TbMarkersDataLayer, L.Marker> {
private marker: L.Marker;
private labelOffset: L.PointTuple;
private iconClassList: string[];
private moving = false;
constructor(data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[],
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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void {
this.marker.options.tbMarkerData = data;
this.updateMarkerLocation(data, dsData);
this.updateTooltip(data, dsData);
this.updateMarkerIcon(data, dsData);
}
protected doInvalidateCoordinates(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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<any> {
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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) {
const location = this.dataLayer.dataProcessor.extractLocation(data, dsData);
if (!this.marker.getLatLng().equals(location) && !this.moving) {
this.marker.setLatLng(location);
}
}
private updateMarkerIcon(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) {
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<MarkersDataLayerSettings, TbMarkersDataLayer> {
public dataProcessor: MarkerDataProcessor;
public markerOffset: L.LatLngTuple;
public tooltipOffset: L.LatLngTuple;
private markersClusterContainer: L.MarkerClusterGroup;
private clusterMarkerColorFunction: CompiledTbFunction<ClusterMarkerColorFunction>;
constructor(protected map: TbMap<any>,
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<TbMapDatasource>, 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 defaultBaseSettings(map: TbMap<any>): Partial<MarkersDataLayerSettings> {
return defaultBaseMarkersDataLayerSettings(map.type());
}
protected doSetup(): Observable<void> {
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<void>[] = [this.dataProcessor.setup()];
if (this.settings.markerClustering?.enable && this.settings.markerClustering.useClusterMarkerColorFunction) {
setup$.push(
parseTbFunction<ClusterMarkerColorFunction>(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<TbMapDatasource>): boolean {
return !!this.dataProcessor.extractLocationData(layerData);
}
protected createLayerItem(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): TbMarkerDataLayerItem {
return new TbMarkerDataLayerItem(data, dsData, this.settings, this);
}
protected layerItemsUpdated(updatedItems: TbLatestDataLayerItem<MarkersDataLayerSettings, TbMarkersDataLayer, L.Marker>[]) {
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: `<div style="background-color: ${parsedColor.setAlpha(alpha * 0.4).toRgbString()};" ` +
`class="marker-cluster tb-cluster-marker-element">` +
`<div style="background-color: ${parsedColor.setAlpha(alpha * 0.9).toRgbString()};"><span>` + childCount + '</span></div></div>',
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: '<div><span>' + childCount + '</span></div>',
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;
}
}

435
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<PolygonsDataLayerSettings, TbPolygonsDataLayer> {
private polygonContainer: L.FeatureGroup;
private polygon: L.Polygon;
private polygonStyle: L.PathOptions;
private editing = false;
constructor(data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[],
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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L.Layer {
const polyData = this.dataLayer.extractPolygonCoordinates(data);
const polyConstructor = isCutPolygon(polyData) || polyData.length !== 2 ? L.polygon : L.rectangle;
this.polygonStyle = this.dataLayer.getShapeStyle(data, dsData);
this.polygon = polyConstructor(polyData as (TbPolygonRawCoordinates & L.LatLngTuple[]), {
...this.polygonStyle,
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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void {
this.polygonStyle = this.dataLayer.getShapeStyle(data, dsData);
this.updatePolygonShape(data);
this.updateTooltip(data, dsData);
this.updateLabel(data, dsData);
if (!this.editing || !this.dataLayer.getMap().getMap().pm.globalCutModeEnabled()) {
this.polygon.setStyle(this.polygonStyle);
}
}
protected doInvalidateCoordinates(data: FormattedData<TbMapDatasource>, _dsData: FormattedData<TbMapDatasource>[]): 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<any> {
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<TbMapDatasource>) {
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<PolygonsDataLayerSettings, TbPolygonsDataLayer> {
constructor(protected map: TbMap<any>,
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<TbMapDatasource>): TbPolygonRawCoordinates {
let rawPolyData = data[this.settings.polygonKey.label];
if (isString(rawPolyData)) {
rawPolyData = JSON.parse(rawPolyData);
}
return this.map.polygonDataToCoordinates(rawPolyData);
}
public savePolygonCoordinates(data: FormattedData<TbMapDatasource>, coordinates: TbPolygonCoordinates): Observable<TbPolygonRawCoordinates> {
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<any>): Partial<PolygonsDataLayerSettings> {
return defaultBasePolygonsDataLayerSettings(map.type());
}
protected doSetup(): Observable<any> {
return super.doSetup();
}
protected isValidLayerData(layerData: FormattedData<TbMapDatasource>): 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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): TbPolygonDataLayerItem {
return new TbPolygonDataLayerItem(data, dsData, this.settings, this);
}
}

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

@ -0,0 +1,54 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { 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<S extends ShapeDataLayerSettings, L extends TbLatestMapDataLayer<S,L>> extends TbLatestMapDataLayer<S, L> {
public fillColorProcessor: DataLayerColorProcessor;
public strokeColorProcessor: DataLayerColorProcessor;
protected constructor(protected map: TbMap<any>,
inputSettings: S) {
super(map, inputSettings);
}
public getShapeStyle(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L.PathOptions {
const fill = this.fillColorProcessor.processColor(data, dsData);
const stroke = this.strokeColorProcessor.processColor(data, dsData);
return {
fill: true,
fillColor: fill,
color: stroke,
weight: this.settings.strokeWeight,
fillOpacity: 1,
opacity: 1
};
}
protected doSetup(): Observable<any> {
this.fillColorProcessor = new DataLayerColorProcessor(this, this.settings.fillColor);
this.strokeColorProcessor = new DataLayerColorProcessor(this, this.settings.strokeColor);
return forkJoin([this.fillColorProcessor.setup(), this.strokeColorProcessor.setup()]);
}
}

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

@ -0,0 +1,596 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT 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,
defaultBaseTripsDataLayerSettings,
findRotationAngle,
interpolateLineSegment,
MapDataLayerType,
TbMapDatasource,
TripsDataLayerSettings
} from '@shared/models/widget/maps/map.models';
import { forkJoin, Observable } from 'rxjs';
import { 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<TbMapDatasource>};
interface PointItem {
point: L.CircleMarker;
tooltip?: L.Popup;
}
class TbTripDataItem extends TbDataLayerItem<TripsDataLayerSettings, TbTripsDataLayer, L.FeatureGroup> {
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<string, PointItem>();
private currentTime: number;
private currentPositionData: FormattedData<TbMapDatasource>;
private pointData: FormattedData<TbMapDatasource>;
constructor(private rawRouteData: FormattedData<TbMapDatasource>[],
private latestData: FormattedData<TbMapDatasource>,
settings: TripsDataLayerSettings,
dataLayer: TbTripsDataLayer) {
super(settings, dataLayer);
this.tripRouteData = this.prepareTripRouteData();
this.create();
}
public update(rawRouteData: FormattedData<TbMapDatasource>[]) {
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<TbMapDatasource>) {
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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) {
const location = this.dataLayer.dataProcessor.extractLocation(data, dsData);
if (!this.marker.getLatLng().equals(location)) {
this.marker.setLatLng(location);
}
}
private updateMarkerIcon(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) {
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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) {
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 = `<div style="color: ${labelColor};"><b>${label}</b></div>`;
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<TripsDataLayerSettings, TbTripDataItem> {
public dataProcessor: MarkerDataProcessor;
public markerOffset: L.LatLngTuple;
public tooltipOffset: L.LatLngTuple;
public pathStrokeColorProcessor: DataLayerColorProcessor;
public pointColorProcessor: DataLayerColorProcessor;
public pointTooltipProcessor: DataLayerPatternProcessor;
private rawTripsData: FormattedData<TbMapDatasource>[][];
private latestTripsData: FormattedData<TbMapDatasource>[];
constructor(protected map: TbMap<any>,
inputSettings: TripsDataLayerSettings) {
super(map, inputSettings);
}
public dataLayerType(): MapDataLayerType {
return 'trips';
}
public showMarker(): boolean {
return this.settings.showMarker;
}
public prepareTripsData(tripsData: FormattedData<TbMapDatasource>[][], tripsLatestData: FormattedData<TbMapDatasource>[]): {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<TbMapDatasource>[]) {
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<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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];
if (this.settings.additionalDataKeys?.length) {
const tsKeys = this.settings.additionalDataKeys.filter(key => key.type === DataKeyType.timeseries);
const latestKeys = this.settings.additionalDataKeys.filter(key => key.type !== DataKeyType.timeseries);
datasource.dataKeys.push(...tsKeys);
if (latestKeys.length) {
datasource.latestDataKeys = latestKeys;
}
}
return datasource;
}
protected defaultBaseSettings(map: TbMap<any>): Partial<TripsDataLayerSettings> {
return defaultBaseTripsDataLayerSettings(map.type());
}
protected doSetup(): Observable<void> {
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<void>[] = [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<TbMapDatasource>[]): FormattedData<TbMapDatasource>[] {
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<TbMapDatasource>): boolean => {
return isDefined(item[this.settings.xKey.label]) && isDefined(item[this.settings.yKey.label]);
}
}

183
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<GeoMapSettings> {
constructor(protected ctx: WidgetContext,
protected inputSettings: DeepPartial<GeoMapSettings>,
protected containerElement: HTMLElement) {
super(ctx, inputSettings, containerElement);
}
protected defaultSettings(): GeoMapSettings {
return defaultGeoMapSettings;
}
protected createMap(): Observable<L.Map> {
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<any> {
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<L.TB.LayerData[]> {
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;
}
}

342
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<ImageMapSettings> {
private maxZoom: number;
private width: number;
private height: number;
private imageLayerData: ImageLayerData;
private initMapSubject: ReplaySubject<L.Map>;
private imageOverlay: L.ImageOverlay;
constructor(protected ctx: WidgetContext,
protected inputSettings: DeepPartial<ImageMapSettings>,
protected containerElement: HTMLElement) {
super(ctx, inputSettings, containerElement);
}
protected defaultSettings(): ImageMapSettings {
return defaultImageMapSettings;
}
protected createMap(): Observable<L.Map> {
this.maxZoom = 4;
this.width = 0;
this.height = 0;
this.imageLayerData = {
imageUrl: null,
aspect: 0
};
this.initMapSubject = new ReplaySubject<L.Map>();
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<ImageLayerData> {
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<ImageLayerData> {
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<ImageLayerData> {
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))
);
})
);
}
}

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

@ -0,0 +1,674 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT 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 '@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<TB.SidebarControlOptions> {
private readonly sidebar: JQuery<HTMLElement>;
private current = $();
private currentButton = $();
private map: L.Map;
private buttonContainer: JQuery<HTMLElement>;
constructor(options: TB.SidebarControlOptions) {
super(options);
this.sidebar = $('<div class="tb-map-sidebar"></div>');
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<HTMLElement>, button: JQuery<HTMLElement>): this {
pane.hide().appendTo(this.sidebar);
button.appendTo(this.buttonContainer);
return this;
}
togglePane(pane: JQuery<HTMLElement>, button: JQuery<HTMLElement>) {
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 = $("<div>")
.attr('class', 'leaflet-bar');
return this.buttonContainer[0];
}
addTo(map: L.Map): this {
this.map = map;
return super.addTo(map);
}
}
class SidebarPaneControl<O extends TB.SidebarPaneControlOptions> extends L.Control<O> {
private button: JQuery<HTMLElement>;
private $ui: JQuery<HTMLElement>;
constructor(options: O) {
super(options);
}
addTo(map: L.Map): this {
this.button = $("<a>")
.attr('class', 'tb-control-button')
.attr('href', '#')
.attr('role', 'button')
.html('<div class="' + this.options.uiClass + '"></div>')
.on('click', (e) => {
this.toggle(e);
});
if (this.options.buttonTitle) {
this.button.attr('title', this.options.buttonTitle);
}
this.$ui = $('<div>')
.attr('class', this.options.uiClass);
$('<div class="tb-layers-title-container">')
.appendTo(this.$ui)
.append($('<div class="tb-layers-title">')
.text(this.options.paneTitle))
.append($('<div>')
.append($('<button type="button" class="tb-button-close mdc-icon-button mat-mdc-icon-button">' +
'<span class="mat-mdc-button-persistent-ripple mdc-icon-button__ripple"></span>' +
'<span class="material-icons">close</span>' +
'</button>')
.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<HTMLElement>, $ui: JQuery<HTMLElement>, 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<TB.LayersControlOptions> {
constructor(options: TB.LayersControlOptions) {
super(options);
}
public onAddPane(map: L.Map, button: JQuery<HTMLElement>, $ui: JQuery<HTMLElement>, toggle: (e: JQuery.MouseEventBase) => void) {
const paneId = guid();
const layers = this.options.layers;
const baseSection = $("<div>")
.attr('class', 'tb-layers-container')
.appendTo($ui);
layers.forEach((layerData, i) => {
const id = `map-ui-layer-${paneId}-${i}`;
const buttonContainer = $('<div class="tb-layer-card">')
.appendTo(baseSection);
const mapContainer = $('<div class="tb-layer-map">')
.appendTo(buttonContainer);
const input = $('<input type="radio" class="tb-layer-button" name="layer">')
.prop('id', id)
.prop('checked', map.hasLayer(layerData.layer))
.appendTo(buttonContainer);
const item = $('<label class="tb-layer-label">')
.prop('for', id)
.append($('<span>').append(layerData.title))
.appendTo(buttonContainer);
map.whenReady(() => {
const miniMap = L.map(mapContainer[0], { attributionControl: false, zoomControl: false, keyboard: false })
.addLayer(layerData.mini);
miniMap.dragging.disable();
miniMap.touchZoom.disable();
miniMap.doubleClickZoom.disable();
miniMap.scrollWheelZoom.disable();
const moved = () => {
miniMap.setView(map.getCenter(), Math.max(map.getZoom() - 2, 0));
};
const shown = () => {
miniMap.invalidateSize();
miniMap.setView(map.getCenter(), Math.max(map.getZoom() - 2, 0), { animate: false });
map.on('moveend', moved);
};
$ui.on('show', shown);
$ui.on('hide', () => {
map.off('moveend', moved);
})
});
input.on('click', (e: JQuery.MouseEventBase) => {
e.stopPropagation();
layers.forEach((other) => {
if (other.layer === layerData.layer) {
map.addLayer(other.layer);
map.attributionControl.setPrefix(other.attributionPrefix);
} else {
map.removeLayer(other.layer);
}
});
map.fire('baselayerchange', { layer: layerData.layer });
});
item.on('dblclick', (e) => {
toggle(e);
});
map.on('layeradd layerremove', function () {
input.prop('checked', map.hasLayer(layerData.layer));
});
});
}
}
class GroupsControl extends SidebarPaneControl<TB.GroupsControlOptions> {
constructor(options: TB.GroupsControlOptions) {
super(options);
}
public onAddPane(map: L.Map, button: JQuery<HTMLElement>, $ui: JQuery<HTMLElement>, toggle: (e: JQuery.MouseEventBase) => void) {
const paneId = guid();
const groups = this.options.groups;
const baseSection = $("<div>")
.attr('class', 'tb-layers-container')
.appendTo($ui);
groups.forEach((groupData, i) => {
const id = `map-group-layer-${paneId}-${i}`;
const checkBoxContainer = $('<div class="tb-group-checkbox">')
.appendTo(baseSection);
const input = $('<input type="checkbox" class="tb-group-button" name="group">')
.prop('id', id)
.prop('checked', groupData.enabled)
.appendTo(checkBoxContainer);
$('<label class="tb-group-label">')
.prop('title', groupData.title)
.prop('for', id)
.append($('<span>').append(groupData.title))
.appendTo(checkBoxContainer);
input.on('click', (e: JQuery.MouseEventBase) => {
e.stopPropagation();
groupData.enabled = !groupData.enabled;
let changed = false;
groupData.dataLayers.forEach(
(dl) => {
changed = dl.toggleGroup(groupData.group) || changed;
}
);
if (changed) {
map.fire('layergroupchange', {group: groupData});
}
});
map.on('layeradd layerremove', function () {
input.prop('checked', groupData.enabled);
});
});
}
}
class TopToolbarButton {
private readonly button: JQuery<HTMLElement>;
private active = false;
private disabled = false;
private _onClick: (e: MouseEvent, button: TopToolbarButton) => void;
constructor(private readonly options: TB.TopToolbarButtonOptions,
private readonly iconRegistry: MatIconRegistry) {
const iconElement = $('<div class="tb-control-button-icon"></div>');
const setIcon = isNotEmptyStr(this.options.icon);
const setTitle = isNotEmptyStr(this.options.title);
this.button = $("<a>")
.attr('class', 'tb-control-button tb-control-text-button')
.attr('href', '#')
.attr('role', 'button');
if (setIcon) {
this.button.append(iconElement);
this.loadIcon(iconElement);
}
if (setTitle) {
this.button.append(`<div class="tb-control-text">${this.options.title}</div>`);
}
this.button.css('--tb-map-control-color', this.options.color);
this.button.css('--tb-map-control-active-color', this.options.color);
this.button.css('--tb-map-control-hover-background-color', this.options.color);
if (setIcon && !setTitle) {
this.button.css('padding', 0);
} else if (!setIcon && setTitle) {
this.button.css('padding-left', '14px');
}
this.button.on('click', (e) => {
e.stopPropagation();
e.preventDefault();
if (this._onClick) {
this._onClick(e.originalEvent, this);
}
});
}
onClick(onClick: (e: MouseEvent, button: TopToolbarButton) => void): void {
this._onClick = onClick;
}
private loadIcon(iconElement: JQuery<HTMLElement>) {
iconElement.addClass(this.iconRegistry.getDefaultFontSetClass());
if (!isSvgIcon(this.options.icon)) {
iconElement.addClass('material-icon-font');
iconElement.text(this.options.icon);
return;
}
const [namespace, iconName] = splitIconName(this.options.icon);
this.iconRegistry
.getNamedSvgIcon(iconName, namespace)
.pipe(
take(1),
catchError((err: Error) => {
console.log(`Error retrieving icon ${namespace}:${iconName}! ${err.message}`)
return of(null);
})
)
.subscribe({
next: (svg) => {
iconElement.append(svg);
svg.style.height = '24px';
svg.style.width = '24px';
}
});
}
setActive(active: boolean): void {
if (this.active !== active) {
this.active = active;
if (this.active) {
L.DomUtil.addClass(this.button[0], 'active');
} else {
L.DomUtil.removeClass(this.button[0], 'active');
}
}
}
isActive(): boolean {
return this.active;
}
setDisabled(disabled: boolean): void {
if (this.disabled !== disabled) {
this.disabled = disabled;
if (this.disabled) {
L.DomUtil.addClass(this.button[0], 'leaflet-disabled');
this.button[0].setAttribute('aria-disabled', 'true');
} else {
L.DomUtil.removeClass(this.button[0], 'leaflet-disabled');
this.button[0].setAttribute('aria-disabled', 'false');
}
}
}
isDisabled(): boolean {
return this.disabled;
}
getButtonElement(): JQuery<HTMLElement> {
return this.button;
}
}
class ToolbarButton {
private readonly id: string;
private readonly button: JQuery<HTMLElement>;
private active = false;
private disabled = false;
constructor(private readonly options: TB.ToolbarButtonOptions) {
this.id = options.id;
const buttonText = this.options.showText ? this.options.title : null;
this.button = $("<a>")
.attr('class', 'tb-control-button')
.attr('href', '#')
.attr('role', 'button')
.html('<div class="'+this.options.iconClass+'"></div>' + (buttonText ? `<div class="tb-control-text">${buttonText}</div>` : ''));
if (this.options.showText) {
L.DomUtil.addClass(this.button[0], 'tb-control-text-button');
} else {
this.button.attr('title', this.options.title);
}
this.button.on('click', (e) => {
e.stopPropagation();
e.preventDefault();
this.options.click(e.originalEvent, this);
});
}
setActive(active: boolean): void {
if (this.active !== active) {
this.active = active;
if (this.active) {
L.DomUtil.addClass(this.button[0], 'active');
} else {
L.DomUtil.removeClass(this.button[0], 'active');
}
}
}
isActive(): boolean {
return this.active;
}
setDisabled(disabled: boolean): void {
if (this.disabled !== disabled) {
this.disabled = disabled;
if (this.disabled) {
L.DomUtil.addClass(this.button[0], 'leaflet-disabled');
this.button[0].setAttribute('aria-disabled', 'true');
} else {
L.DomUtil.removeClass(this.button[0], 'leaflet-disabled');
this.button[0].setAttribute('aria-disabled', 'false');
}
}
}
isDisabled(): boolean {
return this.disabled;
}
getId(): string {
return this.id;
}
getButtonElement(): JQuery<HTMLElement> {
return this.button;
}
}
class TopToolbarControl {
private readonly toolbarElement: JQuery<HTMLElement>;
private buttons: Array<TopToolbarButton> = [];
constructor(private readonly options: TB.TopToolbarControlOptions) {
const controlContainer = $('.leaflet-control-container', this.options.mapElement);
this.toolbarElement = $('<div class="tb-map-top-toolbar leaflet-top"></div>');
this.toolbarElement.appendTo(controlContainer);
}
toolbarButton(options: TB.TopToolbarButtonOptions): TopToolbarButton {
const button = new TopToolbarButton(options, this.options.iconRegistry);
const buttonContainer = $('<div class="leaflet-bar leaflet-control"></div>');
button.getButtonElement().appendTo(buttonContainer);
buttonContainer.appendTo(this.toolbarElement);
this.buttons.push(button);
return button;
}
setDisabled(disabled: boolean): void {
this.buttons.forEach(button => {
if (!button.isActive()) {
button.setDisabled(disabled);
}
});
}
}
class ToolbarControl extends L.Control<L.ControlOptions> {
private buttonContainer: JQuery<HTMLElement>;
constructor(options: L.ControlOptions) {
super(options);
}
toolbarButton(options: TB.ToolbarButtonOptions): ToolbarButton {
const button = new ToolbarButton(options);
button.getButtonElement().appendTo(this.buttonContainer);
return button;
}
onAdd(map: L.Map): HTMLElement {
this.buttonContainer = $("<div>")
.attr('class', 'leaflet-bar');
return this.buttonContainer[0];
}
addTo(map: L.Map): this {
return super.addTo(map);
}
}
class BottomToolbarControl {
private readonly buttonContainer: JQuery<HTMLElement>;
private toolbarButtons: ToolbarButton[] = [];
container: HTMLElement;
constructor(private readonly options: TB.BottomToolbarControlOptions) {
const controlContainer = $('.leaflet-control-container', options.mapElement);
const toolbar = $('<div class="tb-map-bottom-toolbar leaflet-bottom"></div>');
toolbar.appendTo(controlContainer);
this.buttonContainer = $('<div class="leaflet-bar leaflet-control"></div>');
this.buttonContainer.appendTo(toolbar);
this.container = this.buttonContainer[0];
}
getButton(id: string): ToolbarButton {
return this.toolbarButtons.find(b => b.getId() === id);
}
open(buttons: TB.ToolbarButtonOptions[], showCloseButton = true): void {
this.toolbarButtons.length = 0;
buttons.forEach(buttonOption => {
const button = new ToolbarButton(buttonOption);
this.toolbarButtons.push(button);
button.getButtonElement().appendTo(this.container);
});
if (showCloseButton) {
const closeButton = $("<a>")
.attr('class', 'tb-control-button')
.attr('href', '#')
.attr('role', 'button')
.attr('title', this.options.closeTitle)
.html('<div class="tb-close"></div>');
closeButton.on('click', (e) => {
e.stopPropagation();
e.preventDefault();
this.close();
});
closeButton.appendTo(this.buttonContainer);
}
}
close(): void {
if (this.options.onClose) {
if (this.options.onClose()) {
this.buttonContainer.empty();
}
} else {
this.buttonContainer.empty();
}
}
}
const sidebar = (options: TB.SidebarControlOptions): SidebarControl => {
return new SidebarControl(options);
}
const sidebarPane = <O extends TB.SidebarPaneControlOptions>(options: O): SidebarPaneControl<O> => {
return new SidebarPaneControl(options);
}
const layers = (options: TB.LayersControlOptions): LayersControl => {
return new LayersControl(options);
}
const groups = (options: TB.GroupsControlOptions): GroupsControl => {
return new GroupsControl(options);
}
const topToolbar = (options: TB.TopToolbarControlOptions): TopToolbarControl => {
return new TopToolbarControl(options);
}
const toolbar = (options: L.ControlOptions): ToolbarControl => {
return new ToolbarControl(options);
}
const bottomToolbar = (options: TB.BottomToolbarControlOptions): BottomToolbarControl => {
return new BottomToolbarControl(options);
}
class ChinaProvider extends L.TileLayer {
static chinaProviders: L.TB.TileLayer.ChinaProvidersData = {
Tencent: {
Normal: "//rt{s}.map.gtimg.com/tile?z={z}&x={x}&y={-y}&type=vector&styleid=3",
Satellite: "//p{s}.map.gtimg.com/sateTiles/{z}/{sx}/{sy}/{x}_{-y}.jpg",
Terrain: "//p{s}.map.gtimg.com/demTiles/{z}/{sx}/{sy}/{x}_{-y}.jpg",
Subdomains: '0123',
}
};
constructor(type: string, options?: L.TileLayerOptions) {
options = options || {};
const parts = type.split('.');
const providerName = parts[0];
const mapName = parts[1];
const url = ChinaProvider.chinaProviders[providerName][mapName];
options.subdomains = ChinaProvider.chinaProviders[providerName].Subdomains;
super(url, options);
}
getTileUrl(coords: L.Coords): string {
const data = {
s: this._getSubdomain(coords),
x: coords.x,
y: coords.y,
z: this._getZoomForUrl(),
sx: null,
sy: null
};
if (this._map && !this._map.options.crs.infinite) {
const invertedY = this._globalTileRange.max.y - coords.y;
if (this.options.tms) {
data['y'] = invertedY;
}
data['-y'] = invertedY;
}
data.sx = data.x >> 4;
data.sy = (( 1 << data.z) - data.y) >> 4;
return L.Util.template(this._url, L.Util.extend(data, this.options));
}
}
const chinaProvider = (type: string, options?: L.TileLayerOptions): ChinaProvider => {
return new ChinaProvider(type, options);
}
L.TB = L.TB || {
SidebarControl,
SidebarPaneControl,
LayersControl,
GroupsControl,
TopToolbarButton,
TopToolbarControl,
ToolbarButton,
ToolbarControl,
BottomToolbarControl,
sidebar,
sidebarPane,
layers,
groups,
topToolbar,
toolbar,
bottomToolbar,
TileLayer: {
ChinaProvider
},
tileLayer: {
chinaProvider
}
}

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

@ -0,0 +1,239 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT 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 {
CustomMapLayerSettings,
defaultCustomMapLayerSettings,
defaultGoogleMapLayerSettings,
defaultHereMapLayerSettings,
defaultLayerTitle,
defaultOpenStreetMapLayerSettings,
defaultTencentMapLayerSettings,
GoogleMapLayerSettings,
HereMapLayerSettings,
MapLayerSettings,
MapProvider,
OpenStreetMapLayerSettings,
TencentMapLayerSettings
} from '@shared/models/widget/maps/map.models';
import { WidgetContext } from '@home/models/widget-component.models';
import { DeepPartial } from '@shared/models/common';
import { mergeDeep } from '@core/utils';
import { Observable, of, switchMap } from 'rxjs';
import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe';
import L from 'leaflet';
import { catchError, map } from 'rxjs/operators';
import { ResourcesService } from '@core/services/resources.service';
export abstract class TbMapLayer<S extends MapLayerSettings> {
static fromSettings(ctx: WidgetContext,
inputSettings: DeepPartial<MapLayerSettings>) {
switch (inputSettings.provider) {
case MapProvider.openstreet:
return new TbOpenStreetMapLayer(ctx, inputSettings);
case MapProvider.google:
return new TbGoogleMapLayer(ctx, inputSettings);
case MapProvider.tencent:
return new TbTencentMapLayer(ctx, inputSettings);
case MapProvider.here:
return new TbHereMapLayer(ctx, inputSettings);
case MapProvider.custom:
return new TbCustomMapLayer(ctx, inputSettings);
}
}
protected settings: S;
protected constructor(protected ctx: WidgetContext,
protected inputSettings: DeepPartial<MapLayerSettings>) {
this.settings = mergeDeep({} as S, this.defaultSettings(), this.inputSettings as S);
}
public loadLayer(theMap: L.Map): Observable<L.TB.LayerData> {
return this.createLayer().pipe(
switchMap((layer) => {
if (layer) {
return this.createLayer().pipe(
map((mini) => {
if (mini) {
const attribution = layer.getAttribution();
const attributionPrefix = attribution ? theMap.attributionControl.options.prefix as string : null;
return {
title: this.title(),
attributionPrefix: attributionPrefix,
layer,
mini
};
} else {
return null;
}
})
);
} else {
return of(null);
}
})
);
}
private title(): string {
const customTranslate = this.ctx.$injector.get(CustomTranslatePipe);
if (this.settings.label) {
return customTranslate.transform(this.settings.label);
} else {
return this.generateTitle();
}
}
private generateTitle(): string {
const translationKey = defaultLayerTitle(this.settings);
if (translationKey) {
return this.ctx.translate.instant(translationKey);
} else {
return 'Unknown';
}
};
protected abstract defaultSettings(): S;
protected abstract createLayer(): Observable<L.Layer>;
}
class TbOpenStreetMapLayer extends TbMapLayer<OpenStreetMapLayerSettings> {
constructor(protected ctx: WidgetContext,
protected inputSettings: DeepPartial<MapLayerSettings>) {
super(ctx, inputSettings);
}
protected defaultSettings(): OpenStreetMapLayerSettings {
return defaultOpenStreetMapLayerSettings;
}
protected createLayer(): Observable<L.Layer> {
const layer = L.tileLayer.provider(this.settings.layerType);
return of(layer);
}
}
class TbGoogleMapLayer extends TbMapLayer<GoogleMapLayerSettings> {
static loadedApiKeysGlobal: {[key: string]: boolean} = {};
constructor(protected ctx: WidgetContext,
protected inputSettings: DeepPartial<MapLayerSettings>) {
super(ctx, inputSettings);
}
protected defaultSettings(): GoogleMapLayerSettings {
return defaultGoogleMapLayerSettings;
}
protected createLayer(): Observable<L.Layer> {
return this.loadGoogle().pipe(
map((loaded) => {
if (loaded) {
return (L.gridLayer as any).googleMutant({
type: this.settings.layerType
});
} else {
return null;
}
})
);
}
private loadGoogle(): Observable<boolean> {
const apiKey = this.settings.apiKey || defaultGoogleMapLayerSettings.apiKey;
if (TbGoogleMapLayer.loadedApiKeysGlobal[apiKey]) {
return of(true);
} else {
const resourceService = this.ctx.$injector.get(ResourcesService);
return resourceService.loadResource(`https://maps.googleapis.com/maps/api/js?key=${apiKey}&loading=async`).pipe(
map(() => {
TbGoogleMapLayer.loadedApiKeysGlobal[apiKey] = true;
return true;
}),
catchError((e) => {
TbGoogleMapLayer.loadedApiKeysGlobal[apiKey] = false;
console.error(`Google map api load failed!`, e);
return of(false);
})
);
}
}
}
class TbTencentMapLayer extends TbMapLayer<TencentMapLayerSettings> {
constructor(protected ctx: WidgetContext,
protected inputSettings: DeepPartial<MapLayerSettings>) {
super(ctx, inputSettings);
}
protected defaultSettings(): TencentMapLayerSettings {
return defaultTencentMapLayerSettings;
}
protected createLayer(): Observable<L.Layer> {
const layer = L.TB.tileLayer.chinaProvider(this.settings.layerType, {
attribution: '&copy;2024 Tencent - GS(2023)1171号'
});
return of(layer);
}
}
class TbHereMapLayer extends TbMapLayer<HereMapLayerSettings> {
constructor(protected ctx: WidgetContext,
protected inputSettings: DeepPartial<MapLayerSettings>) {
super(ctx, inputSettings);
}
protected defaultSettings(): HereMapLayerSettings {
return defaultHereMapLayerSettings;
}
protected createLayer(): Observable<L.Layer> {
const apiKey = this.settings.apiKey || defaultHereMapLayerSettings.apiKey;
const layer = L.tileLayer.provider(this.settings.layerType, {useV3: true, apiKey} as any);
return of(layer);
}
}
class TbCustomMapLayer extends TbMapLayer<CustomMapLayerSettings> {
constructor(protected ctx: WidgetContext,
protected inputSettings: DeepPartial<MapLayerSettings>) {
super(ctx, inputSettings);
}
protected defaultSettings(): CustomMapLayerSettings {
return defaultCustomMapLayerSettings;
}
protected createLayer(): Observable<L.Layer> {
const layer = L.tileLayer(this.settings.tileUrl);
return of(layer);
}
}

25
ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.html

@ -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.
-->
<div class="tb-map-panel" [style.padding]="padding" [style]="backgroundStyle$ | async">
<div class="tb-map-overlay" [style]="overlayStyle"></div>
<ng-container *ngIf="widgetComponent.dashboardWidget.showWidgetTitlePanel">
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container>
</ng-container>
<div #mapElement>
</div>
</div>

37
ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.scss

@ -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.
*/
.tb-map-panel {
width: 100%;
height: 100%;
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 20px 24px 24px 24px;
> div:not(.tb-map-overlay) {
z-index: 1;
}
.tb-map-overlay {
position: absolute;
top: 12px;
left: 12px;
bottom: 12px;
right: 12px;
}
div.tb-widget-title {
padding: 0;
}
}

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

@ -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.
///
import {
ChangeDetectorRef,
Component,
ElementRef,
Input,
OnDestroy,
OnInit,
TemplateRef,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import {
createMap,
mapWidgetDefaultSettings,
MapWidgetSettings
} from '@home/components/widget/lib/maps/map-widget.models';
import { WidgetContext } from '@home/models/widget-component.models';
import { Observable } from 'rxjs';
import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models';
import { TbMap } from '@home/components/widget/lib/maps/map';
import { MapSetting } from '@shared/models/widget/maps/map.models';
import { WidgetComponent } from '@home/components/widget/widget.component';
import { ImagePipe } from '@shared/pipe/image.pipe';
import { DomSanitizer } from '@angular/platform-browser';
@Component({
selector: 'tb-map-widget',
templateUrl: './map-widget.component.html',
styleUrls: ['./map-widget.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class MapWidgetComponent implements OnInit, OnDestroy {
@ViewChild('mapElement', {static: false})
mapElement: ElementRef<HTMLElement>;
settings: MapWidgetSettings;
@Input()
ctx: WidgetContext;
@Input()
widgetTitlePanel: TemplateRef<any>;
backgroundStyle$: Observable<ComponentStyle>;
overlayStyle: ComponentStyle = {};
padding: string;
private map: TbMap<MapSetting>;
constructor(public widgetComponent: WidgetComponent,
private imagePipe: ImagePipe,
private sanitizer: DomSanitizer,
private cd: ChangeDetectorRef) {
}
ngOnInit(): void {
this.ctx.$scope.mapWidget = this;
this.settings = {...mapWidgetDefaultSettings, ...this.ctx.settings};
this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer);
this.overlayStyle = overlayStyle(this.settings.background.overlay);
this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding;
}
ngOnDestroy() {
if (this.map) {
this.map.destroy();
}
}
public onInit() {
const borderRadius = this.ctx.$widgetElement.css('borderRadius');
this.overlayStyle = {...this.overlayStyle, ...{borderRadius}};
this.cd.detectChanges();
this.map = createMap(this.ctx, this.settings, this.mapElement.nativeElement);
}
}

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

@ -0,0 +1,54 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { defaultMapSettings, MapSetting, MapType } from '@shared/models/widget/maps/map.models';
import { BackgroundSettings, BackgroundType } from '@shared/models/widget-settings.models';
import { mergeDeep } from '@core/utils';
import { WidgetContext } from '@home/models/widget-component.models';
import { DeepPartial } from '@shared/models/common';
import { TbMap } from '@home/components/widget/lib/maps/map';
import { TbGeoMap } from '@home/components/widget/lib/maps/geo-map';
import { TbImageMap } from '@home/components/widget/lib/maps/image-map';
export interface MapWidgetSettings extends MapSetting {
background: BackgroundSettings;
padding: string;
}
export const mapWidgetDefaultSettings: MapWidgetSettings =
mergeDeep({} as MapWidgetSettings, defaultMapSettings as MapWidgetSettings, {
background: {
type: BackgroundType.color,
color: '#fff',
overlay: {
enabled: false,
color: 'rgba(255,255,255,0.72)',
blur: 3
}
},
padding: '8px'
} as MapWidgetSettings);
export const createMap = (ctx: WidgetContext,
inputSettings: DeepPartial<MapSetting>,
mapElement: HTMLElement): TbMap<MapSetting> => {
switch (inputSettings.mapType) {
case MapType.geoMap:
return new TbGeoMap(ctx, inputSettings, mapElement);
case MapType.image:
return new TbImageMap(ctx, inputSettings, mapElement);
}
}

425
ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss

@ -0,0 +1,425 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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';
div.tb-widget .tb-widget-content.tb-no-interaction {
.tb-map {
.leaflet-interactive, .leaflet-control {
pointer-events: none;
}
}
}
.tb-map-container, .tb-map-layout {
position: relative;
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
flex: 1;
}
.tb-map-container {
flex-direction: column;
}
.tb-map-layout {
&.tb-sidebar-left {
flex-direction: row-reverse;
}
&.tb-sidebar-right {
flex-direction: row;
}
.tb-map {
position: relative;
flex: 1;
&.leaflet-touch {
.leaflet-bar {
border: 1px solid rgba(0,0,0,0.38);
border-radius: 15px;
box-shadow: 0 4px 6px 0 rgba(0, 0, 0, 0.32);
background-color: #fff;
position: relative;
a {
color: var(--tb-map-control-color, rgba(0, 0, 0, 0.54));
border-bottom: none;
position: relative;
background-color: transparent;
&.leaflet-disabled {
pointer-events: none;
color: var(--tb-map-control-disable-color, rgba(0, 0, 0, 0.18));
> div:not(.material-icon-font):not(.tb-control-text) {
background-color: var(--tb-map-control-disable-color, rgba(0, 0, 0, 0.18));
svg {
fill: var(--tb-map-control-disable-color, rgba(0, 0, 0, 0.18));
}
}
}
&:not(.leaflet-disabled) {
&:hover, &.active {
border-bottom: none;
color: var(--tb-map-control-active-color, $tb-primary-color); // primary color
&:before {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background-color: var(--tb-map-control-hover-background-color, rgba(0, 105, 92));
opacity: 0.1;
}
}
}
&:first-child {
border-top-left-radius: 50%;
border-top-right-radius: 50%;
}
&:last-child {
border-bottom-left-radius: 50%;
border-bottom-right-radius: 50%;
}
&.tb-control-button {
&.tb-control-text-button {
display: flex;
width: auto;
padding-right: 14px;
padding-left: 2px;
div.tb-control-text {
width: auto;
background: transparent;
font-family: Roboto, "Helvetica Neue", sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 500;
}
}
&:not(.leaflet-disabled) {
&.active, &:hover {
&:before {
border-radius: 15px;
}
> div:not(.tb-control-text):not(.tb-close) {
background-color: var(--tb-map-control-active-color, $tb-primary-color);
svg {
fill: var(--tb-map-control-active-color, $tb-primary-color);
}
}
}
> div:not(.material-icon-font):not(.tb-control-text) {
background-color: var(--tb-map-control-color, rgba(0, 0, 0, 0.54));
svg {
fill: var(--tb-map-control-color, rgba(0, 0, 0, 0.54));
}
}
}
> div {
width: 30px;
height: 30px;
line-height: 30px;
mask-repeat: no-repeat;
mask-position: center;
&.tb-layers {
mask-image: url('data:image/svg+xml,<svg width="18" height="18" viewBox="0 0 18 16" xmlns="http://www.w3.org/2000/svg"><path d="M9 9.5L0.75 5L9 0.5L17.25 5L9 9.5ZM9 12.5L1.18125 8.24375L2.75625 7.38125L9 10.7938L15.2438 7.38125L16.8188 8.24375L9 12.5ZM9 15.5L1.18125 11.2438L2.75625 10.3813L9 13.7938L15.2438 10.3813L16.8188 11.2438L9 15.5Z"/></svg>');
}
&.tb-groups {
mask-image: url('data:image/svg+xml,<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M6 13.5C5.5875 13.5 5.2345 13.3533 4.941 13.0597C4.6475 12.7662 4.5005 12.413 4.5 12V3C4.5 2.5875 4.647 2.2345 4.941 1.941C5.235 1.6475 5.588 1.5005 6 1.5H15C15.4125 1.5 15.7657 1.647 16.0597 1.941C16.3538 2.235 16.5005 2.588 16.5 3V12C16.5 12.4125 16.3533 12.7657 16.0597 13.0597C15.7662 13.3538 15.413 13.5005 15 13.5H6ZM6 4.5H15V3H6V4.5ZM3 16.5C2.5875 16.5 2.2345 16.3533 1.941 16.0597C1.6475 15.7662 1.5005 15.413 1.5 15V4.5H3V15H13.5V16.5H3Z"/></svg>');
}
&.tb-remove {
mask-image: url('data:image/svg+xml,<svg width="20" height="20" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M4.5 14.25C4.5 14.6478 4.65804 15.0294 4.93934 15.3107C5.22064 15.592 5.60218 15.75 6 15.75H12C12.3978 15.75 12.7794 15.592 13.0607 15.3107C13.342 15.0294 13.5 14.6478 13.5 14.25V5.25H4.5V14.25ZM6 6.75H12V14.25H6V6.75ZM11.625 3L10.875 2.25H7.125L6.375 3H3.75V4.5H14.25V3H11.625Z"/></svg>');
}
&.tb-cut {
mask-image: url('data:image/svg+xml,<svg width="18" height="18" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M13.25 1.25L8.75 5.75L10.25 7.25L15.5 2V1.25M8 8.375C7.90054 8.375 7.80516 8.33549 7.73483 8.26517C7.66451 8.19484 7.625 8.09946 7.625 8C7.625 7.90054 7.66451 7.80516 7.73483 7.73483C7.80516 7.66451 7.90054 7.625 8 7.625C8.09946 7.625 8.19484 7.66451 8.26517 7.73483C8.33549 7.80516 8.375 7.90054 8.375 8C8.375 8.09946 8.33549 8.19484 8.26517 8.26517C8.19484 8.33549 8.09946 8.375 8 8.375ZM3.5 14C3.10218 14 2.72064 13.842 2.43934 13.5607C2.15804 13.2794 2 12.8978 2 12.5C2 11.6675 2.675 11 3.5 11C3.89782 11 4.27936 11.158 4.56066 11.4393C4.84196 11.7206 5 12.1022 5 12.5C5 13.3325 4.325 14 3.5 14ZM3.5 5C3.10218 5 2.72064 4.84196 2.43934 4.56066C2.15804 4.27936 2 3.89782 2 3.5C2 2.6675 2.675 2 3.5 2C3.89782 2 4.27936 2.15804 4.56066 2.43934C4.84196 2.72064 5 3.10218 5 3.5C5 4.3325 4.325 5 3.5 5ZM6.23 4.73C6.4025 4.355 6.5 3.9425 6.5 3.5C6.5 2.70435 6.18393 1.94129 5.62132 1.37868C5.05871 0.816071 4.29565 0.5 3.5 0.5C2.70435 0.5 1.94129 0.816071 1.37868 1.37868C0.816071 1.94129 0.5 2.70435 0.5 3.5C0.5 4.29565 0.816071 5.05871 1.37868 5.62132C1.94129 6.18393 2.70435 6.5 3.5 6.5C3.9425 6.5 4.355 6.4025 4.73 6.23L6.5 8L4.73 9.77C4.355 9.5975 3.9425 9.5 3.5 9.5C2.70435 9.5 1.94129 9.81607 1.37868 10.3787C0.816071 10.9413 0.5 11.7044 0.5 12.5C0.5 13.2956 0.816071 14.0587 1.37868 14.6213C1.94129 15.1839 2.70435 15.5 3.5 15.5C4.29565 15.5 5.05871 15.1839 5.62132 14.6213C6.18393 14.0587 6.5 13.2956 6.5 12.5C6.5 12.0575 6.4025 11.645 6.23 11.27L8 9.5L13.25 14.75H15.5V14L6.23 4.73Z"/></svg>');
}
&.tb-rotate {
mask-image: url('data:image/svg+xml,<svg width="16" height="16" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg"><path d="M1.77 1.7625C2.8575 0.675 4.35 0 6.0075 0C9.3225 0 12 2.685 12 6C12 9.315 9.3225 12 6.0075 12C3.21 12 0.8775 10.0875 0.21 7.5H1.77C2.385 9.2475 4.05 10.5 6.0075 10.5C8.49 10.5 10.5075 8.4825 10.5075 6C10.5075 3.5175 8.49 1.5 6.0075 1.5C4.7625 1.5 3.6525 2.0175 2.8425 2.835L5.2575 5.25H0.00749922V0L1.77 1.7625Z"/></svg>');
}
&.tb-drag-mode {
mask-image: url('data:image/svg+xml,<svg width="20" height="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13,6V11H18V7.75L22.25,12L18,16.25V13H13V18H16.25L12,22.25L7.75,18H11V13H6V16.25L1.75,12L6,7.75V11H11V6H7.75L12,1.75L16.25,6H13Z"></path></svg>');
}
&.tb-place-marker {
mask-image: url('data:image/svg+xml,<svg width="12" height="16" viewBox="0 0 12 16" xmlns="http://www.w3.org/2000/svg"><path d="M6 0.5C3.0975 0.5 0.75 2.8475 0.75 5.75C0.75 9.6875 6 15.5 6 15.5C6 15.5 11.25 9.6875 11.25 5.75C11.25 2.8475 8.9025 0.5 6 0.5ZM6 7.625C4.965 7.625 4.125 6.785 4.125 5.75C4.125 4.715 4.965 3.875 6 3.875C7.035 3.875 7.875 4.715 7.875 5.75C7.875 6.785 7.035 7.625 6 7.625Z"/></svg>');
}
&.tb-draw-rectangle {
mask-image: url('data:image/svg+xml,<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M1.5 1.5H6V3H12V1.5H16.5V6H15V12H16.5V16.5H12V15H6V16.5H1.5V12H3V6H1.5V1.5ZM12 6V4.5H6V6H4.5V12H6V13.5H12V12H13.5V6H12ZM3 3V4.5H4.5V3H3ZM13.5 3V4.5H15V3H13.5ZM3 13.5V15H4.5V13.5H3ZM13.5 13.5V15H15V13.5H13.5Z"/></svg>');
}
&.tb-draw-polygon {
mask-image: url('data:image/svg+xml,<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M16.5 6V1.5H12V4.35L10.8 6H7.2L6 4.35V1.5H1.5V6H3V12H1.5V16.5H6V15H12V16.5H16.5V12H15V6H16.5ZM8.25 7.5H9.75V9H8.25V7.5ZM3 3H4.5V4.5H3V3ZM4.5 15H3V13.5H4.5V15ZM12 13.5H6V12H4.5V6H5.325L6.75 7.95V10.5H11.25V7.95L12.675 6H13.5V12H12V13.5ZM15 15H13.5V13.5H15V15ZM13.5 4.5V3H15V4.5H13.5Z"/></svg>');
}
&.tb-draw-circle {
mask-image: url('data:image/svg+xml,<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M16.5 6.75H14.9775C14.025 4.0575 11.4825 2.25 8.625 2.25C6.83479 2.25 5.1179 2.96116 3.85203 4.22703C2.58616 5.4929 1.875 7.20979 1.875 9C1.875 12.75 4.8975 15.75 8.625 15.75C11.4825 15.75 14.025 13.95 15 11.25H16.5M15 8.25V9.75H13.5V8.25M13.365 11.25C12.495 13.08 10.65 14.25 8.625 14.25C5.73 14.25 3.375 11.9025 3.375 9C3.375 6.105 5.73 3.75 8.625 3.75C10.65 3.75 12.495 4.9275 13.3575 6.75H12V11.25"/></svg>');
}
&.tb-close {
background: #D12730;
mask-image: url('data:image/svg+xml,<svg width="20" height="20" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M14.25 4.8075L13.1925 3.75L9 7.9425L4.8075 3.75L3.75 4.8075L7.9425 9L3.75 13.1925L4.8075 14.25L9 10.0575L13.1925 14.25L14.25 13.1925L10.0575 9L14.25 4.8075Z"/></svg>');
}
}
}
}
}
.tb-map-bottom-toolbar {
left: 0;
right: 0;
display: flex;
flex-direction: row;
justify-content: center;
.leaflet-bar {
display: flex;
flex-direction: row;
}
}
.tb-map-top-toolbar {
left: 52px;
right: 52px;
display: flex;
flex-direction: row;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
.leaflet-control {
margin-top: 0;
}
a.tb-control-button {
.tb-control-button-icon {
background-clip: text;
}
}
}
}
.leaflet-map-pane:not(.leaflet-zoom-anim) {
.leaflet-marker-icon {
&.tb-hoverable:not(.tb-selected) {
svg {
transition: filter 0.2s;
}
}
}
img.leaflet-marker-icon, path {
&.tb-hoverable:not(.tb-selected) {
transition: filter 0.2s;
}
}
}
.leaflet-marker-icon {
&.tb-marker-div-icon {
background: none;
border: none;
&.tb-draggable {
cursor: move;
}
&.tb-hoverable:not(.tb-selected) {
&:hover {
svg {
filter: brightness(1.3)
drop-shadow( 0 0 4px rgba(0,0,0,0.56))
drop-shadow( 0 0 4px rgba(255,255,255,0.56));
}
}
}
&.tb-selected {
svg {
filter: brightness(1.3)
drop-shadow( 0 0 2px rgba(0,0,0,.6))
drop-shadow( 0 0 4px rgba(255,255,255,.7))
drop-shadow( 0 0 6px rgba(0,0,0,.8))
drop-shadow( 0 0 8px rgba(255,255,255,.9));
}
}
}
}
img.leaflet-marker-icon, path {
&.tb-draggable {
cursor: move;
}
&.tb-hoverable:not(.tb-selected) {
&:hover {
filter: brightness(1.3)
drop-shadow( 0 0 4px rgba(0,0,0,0.56))
drop-shadow( 0 0 4px rgba(255,255,255,0.56));
}
}
}
img.leaflet-marker-icon {
&.tb-selected {
filter: brightness(1.3)
drop-shadow( 0 0 2px rgba(0,0,0,.6))
drop-shadow( 0 0 4px rgba(255,255,255,.7))
drop-shadow( 0 0 6px rgba(0,0,0,.8))
drop-shadow( 0 0 8px rgba(255,255,255,.9));
}
}
path {
&.tb-selected:not(.tb-cut-mode) {
filter: brightness(1.3)
drop-shadow( 0 0 4px rgba(0,0,0,.4))
drop-shadow( 0 0 4px rgba(255,255,255,.3))
drop-shadow( 0 0 8px rgba(0,0,0,.6))
drop-shadow( 0 0 8px rgba(255,255,255,.5));
}
}
.tb-cluster-marker-container {
border: none;
background-color: transparent;
}
.tb-cluster-marker-element {
position: absolute;
top: 0;
left: 0;
width: 40px;
height: 40px;
}
.tb-marker-label, .tb-polygon-label, .tb-circle-label {
border: none;
background: none;
box-shadow: none;
}
.tb-place-item-label {
border: none;
box-shadow: none;
border-radius: 4px;
background: rgba(0,0,0,0.56);
backdrop-filter: blur(4px);
padding: 4px 8px;
color: #fff;
font-family: Roboto, "Helvetica Neue", sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
}
}
.tb-map-sidebar {
.tb-layers, .tb-groups {
height: 100%;
width: 220px;
max-width: 220px;
display: flex;
flex-direction: column;
.tb-layers-title-container {
display: flex;
padding: 4px 4px 4px 16px;
justify-content: space-between;
align-items: center;
align-self: stretch;
.tb-layers-title {
color: rgba(0,0,0,0.87);
font-size: 18px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
button.tb-button-close {
width: 40px;
min-width: 40px;
height: 40px;
padding: 8px;
color: rgba(0, 0, 0, 0.54);
}
}
.tb-layers-container {
display: flex;
flex: 1;
padding: 4px 16px 16px 16px;
flex-direction: column;
align-items: flex-start;
gap: 12px;
align-self: stretch;
overflow: auto;
.tb-layer-card {
position: relative;
height: 64px;
min-height: 64px;
align-self: stretch;
.tb-layer-map {
position: absolute;
inset: 2px;
border-radius: 2px;
z-index: 0;
.leaflet-control-attribution {
display: none;
}
}
input.tb-layer-button {
position: absolute;
left: 0;
bottom: 0;
clip: rect(0, 0, 0, 0);
pointer-events: none;
&:checked+label.tb-layer-label {
border: 3px solid $tb-primary-color;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.30), 0 2px 6px 2px rgba(0, 0, 0, 0.15);
margin: 0;
}
}
label.tb-layer-label {
position: absolute;
inset: 0;
cursor: pointer;
border-radius: 2px;
border: 1px solid rgba(0, 0, 0, 0.12);
margin: 2px;
&:hover {
border: 3px solid $tb-primary-color;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.30), 0 2px 6px 2px rgba(0, 0, 0, 0.15);
margin: 0;
}
> span {
position: absolute;
left: 0;
bottom: 0;
padding: 0 3px;
border-radius: 0 2px 0 0;
background: rgba(0, 0, 0, 0.56);
color: #fff;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px;
user-select: none;
}
}
}
.tb-group-checkbox {
width: 100%;
display: flex;
align-items: center;
gap: 0;
cursor: pointer;
.tb-group-button {
width: 20px;
height: 20px;
min-width: 20px;
min-height: 20px;
margin: 4px;
cursor: pointer;
accent-color: var(--mdc-checkbox-selected-icon-color, var(--mat-app-primary));
}
.tb-group-label {
padding-left: 12px;
font-size: 14px;
font-weight: 400;
color: rgba(0, 0, 0, 0.76);
user-select: none;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
}
}

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

File diff suppressed because it is too large

77
ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html

@ -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.
-->
<div class="tb-map-timeline-panel" [class.col]="column">
@if(hasData) {
<mat-slider
[disabled]="disabled"
[min]="minTimeIndex"
[max]="maxTimeIndex">
<input matSliderThumb [(ngModel)]="index" (input)="onIndexChange(ngSliderThumb.value)" #ngSliderThumb="matSliderThumb">
</mat-slider>
<div class="tb-timeline-controls">
<div class="flex flex-1 flex-row items-center justify-start">
<div class="tb-map-timeline-timestamp" *ngIf="settings.showTimestamp" [innerHTML]="timestampFormat.formatted"></div>
</div>
<div class="tb-timeline-buttons flex flex-1 flex-row items-center justify-center gap-3">
<button mat-mini-fab color="primary"
[disabled]="index === minTimeIndex"
class="tb-timeline-button-32 tb-primary-fill"
(click)="fastRewind()">
<mat-icon>fast_rewind</mat-icon>
</button>
<button mat-mini-fab color="primary"
[disabled]="index === minTimeIndex"
class="tb-timeline-button-32 tb-primary-fill"
(click)="movePrev()">
<mat-icon>skip_previous</mat-icon>
</button>
@if(playing) {
<button mat-mini-fab color="primary" class="tb-primary-fill" (click)="pause()">
<mat-icon>pause</mat-icon>
</button>
} @else {
<button mat-mini-fab color="primary"
[disabled]="index === maxTimeIndex"
class="tb-primary-fill" (click)="play()">
<mat-icon>play_arrow</mat-icon>
</button>
}
<button mat-mini-fab color="primary"
[disabled]="index === maxTimeIndex"
class="tb-timeline-button-32 tb-primary-fill"
(click)="moveNext()">
<mat-icon>skip_next</mat-icon>
</button>
<button mat-mini-fab color="primary"
[disabled]="index === maxTimeIndex"
class="tb-timeline-button-32 tb-primary-fill"
(click)="fastForward()">
<mat-icon>fast_forward</mat-icon>
</button>
</div>
<div class="flex flex-1 flex-row items-center justify-end">
<mat-select class="w-auto" [(ngModel)]="speed" (selectionChange)="speedUpdated()" aria-label="Speed selector">
<mat-select-trigger><span style="padding-right: 10px;">{{speed}}x</span></mat-select-trigger>
<mat-option [value]="speedValue" *ngFor="let speedValue of settings.speedOptions">{{speedValue}}x</mat-option>
</mat-select>
</div>
</div>
} @else {
<div class="tb-map-timeline-no-data flex flex-1 items-center justify-center" translate>widgets.maps.timeline.no-trips-data-available</div>
}
</div>

57
ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss

@ -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.
*/
.tb-map-timeline-panel {
height: 100px;
padding: 4px 16px 8px;
display: flex;
flex-direction: column;
&.col {
height: 156px;
.tb-timeline-controls {
flex-direction: column;
gap: 8px;
}
}
.tb-timeline-controls {
display: flex;
flex-direction: row;
align-items: center;
.tb-map-timeline-timestamp {
font-size: 14px;
font-weight: 400;
color: rgba(0,0,0,0.76);
}
.tb-timeline-buttons {
.mat-mdc-mini-fab.mat-primary {
--mdc-fab-small-container-color: rgba(0,0,0,0);
--mat-fab-small-foreground-color: rgba(0,0,0,0.54);
--mdc-fab-small-container-elevation-shadow: 4px 4px 4px 0px rgba(0, 0, 0, 0.15);
&:before {
opacity: 0.1;
}
}
.tb-timeline-button-32 {
width: 32px;
height: 32px;
}
}
}
.tb-map-timeline-no-data {
font-size: 18px;
font-weight: 400;
color: rgba(0,0,0,0.76);
}
}

269
ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts

@ -0,0 +1,269 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT 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,
ElementRef,
EventEmitter,
Injector,
Input,
OnDestroy,
OnInit,
Output,
ViewEncapsulation
} from '@angular/core';
import { TripTimelineSettings } from '@shared/models/widget/maps/map.models';
import { DateFormatProcessor } from '@shared/models/widget-settings.models';
import { interval, Subscription } from 'rxjs';
import { filter } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'tb-map-timeline-panel',
templateUrl: './map-timeline-panel.component.html',
styleUrls: ['./map-timeline-panel.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class MapTimelinePanelComponent implements OnInit, OnDestroy {
@Input()
settings: TripTimelineSettings;
@Input()
disabled = false;
@Input()
set min(value: number) {
if (this.minValue !== value) {
this.minValue = value;
this.maxTimeIndex = Math.ceil((this.maxValue - this.minValue) / this.settings.timeStep);
this.cd.markForCheck();
}
}
get min(): number {
return this.minValue;
}
@Input()
set max(value: number) {
if (this.maxValue !== value) {
this.maxValue = value;
this.maxTimeIndex = Math.ceil((this.maxValue - this.minValue) / this.settings.timeStep);
this.cd.markForCheck();
}
}
get max(): number {
return this.maxValue;
}
@Input()
set currentTime(time: number) {
if (this.currentTimeValue !== time) {
this.currentTimeValue = time;
if (this.hasData) {
this.index = Math.ceil((this.currentTimeValue - this.minValue) / this.settings.timeStep);
} else {
this.index = 0;
}
this.updateTimestampDisplayValue();
this.cd.markForCheck();
}
}
get currentTime(): number {
return this.currentTimeValue;
}
get hasData(): boolean {
return !!this.currentTimeValue && this.currentTimeValue !== Infinity;
}
set panelElement(element: Element) {
this.panelElementVal = element;
this.panelResize$ = new ResizeObserver(() => {
this.resize();
});
this.panelResize$.observe(element);
}
get panelElement(): Element {
return this.panelElementVal;
}
@Input()
anchors: number[] = [];
@Output()
timeChanged = new EventEmitter<number>();
column = false;
timestampFormat: DateFormatProcessor;
minTimeIndex = 0;
maxTimeIndex = 0;
index = 0;
playing = false;
interval: Subscription;
speed: number;
private minValue: number;
private maxValue: number;
private currentTimeValue: number = null;
private panelResize$: ResizeObserver;
private panelElementVal: Element;
constructor(public element: ElementRef<HTMLElement>,
private cd: ChangeDetectorRef,
private destroyRef: DestroyRef,
private injector: Injector) {
}
ngOnInit() {
if (this.settings.showTimestamp) {
this.timestampFormat = DateFormatProcessor.fromSettings(this.injector, this.settings.timestampFormat);
this.timestampFormat.update(this.currentTime);
}
this.speed = this.settings.speedOptions[0];
}
ngOnDestroy() {
if (this.panelResize$) {
this.panelResize$.disconnect();
}
}
public onIndexChange(index: number) {
this.index = index;
this.updateCurrentTime();
}
public play() {
this.playing = true;
if (!this.interval) {
this.interval = interval(1000 / this.speed)
.pipe(
takeUntilDestroyed(this.destroyRef),
filter(() => this.playing)
).subscribe(
{
next: () => {
if (this.index < this.maxTimeIndex) {
this.index++;
this.updateCurrentTime();
} else {
this.playing = false;
this.cd.markForCheck();
this.interval.unsubscribe();
this.interval = null;
}
},
error: (err) => {
console.error(err);
}
}
);
}
}
public pause() {
this.playing = false;
this.updateCurrentTime();
}
public fastRewind() {
this.index = this.minTimeIndex;
this.pause();
}
public fastForward() {
this.index = this.maxTimeIndex;
this.pause();
}
public moveNext() {
if (this.index < this.maxTimeIndex) {
if (this.settings.snapToRealLocation) {
const anchorIndex = this.findIndex(this.currentTime, this.anchors) + 1;
this.index = Math.floor((this.anchors[anchorIndex] - this.minValue) / this.settings.timeStep);
} else {
this.index++;
}
}
this.pause();
}
public movePrev() {
if (this.index > this.minTimeIndex) {
if (this.settings.snapToRealLocation) {
const anchorIndex = this.findIndex(this.currentTime, this.anchors) - 1;
this.index = Math.floor((this.anchors[anchorIndex] - this.minValue) / this.settings.timeStep);
} else {
this.index--;
}
}
this.pause();
}
public speedUpdated() {
if (this.interval) {
this.interval.unsubscribe();
this.interval = null;
}
if (this.playing) {
this.play();
}
}
private resize(): void {
const width = this.panelElement.getBoundingClientRect().width;
const column = width <= 400;
if (this.column !== column) {
this.column = column;
this.cd.markForCheck();
}
}
private updateCurrentTime() {
const newTime = this.minValue + this.index * this.settings.timeStep;
if (this.currentTime !== newTime) {
this.currentTime = newTime;
this.timeChanged.emit(this.currentTime);
this.updateTimestampDisplayValue();
}
}
private updateTimestampDisplayValue() {
if (this.settings.showTimestamp && this.hasData) {
this.timestampFormat.update(this.currentTime);
this.cd.markForCheck();
}
}
private findIndex(value: number, array: number[]): number {
let i = 0;
while (array[i] < value) {
i++;
}
return i;
}
}

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

@ -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.
-->
<div class="tb-select-map-entity-panel" [formGroup]="selectEntityFormGroup">
<mat-form-field class="mat-block" appearance="outline" hideRequiredMarker>
<mat-label>{{ (selectEntityFormGroup.get('entity').value ? 'entity.entity' : 'widgets.maps.data-layer.select-entity') | translate }}</mat-label>
<mat-select formControlName="entity">
<mat-option *ngFor="let entity of entities" [value]="entity">
{{ entity.entity.entityDisplayName }}
</mat-option>
</mat-select>
<mat-hint translate>widgets.maps.data-layer.select-entity-hint</mat-hint>
</mat-form-field>
<div class="tb-select-map-entity-panel-buttons">
<span class="flex-1"></span>
<button mat-button
color="primary"
type="button"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button
color="primary"
type="button"
(click)="selectEntity()"
[disabled]="selectEntityFormGroup.invalid || !selectEntityFormGroup.dirty">
{{ 'action.select' | translate }}
</button>
</div>
</div>

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

@ -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.
*/
@import '../../../../../../../../scss/constants';
.tb-select-map-entity-panel {
width: 320px;
max-width: 90vw;
display: flex;
flex-direction: column;
gap: 16px;
@media #{$mat-xs} {
width: 90vw;
}
.mat-mdc-form-field-hint-wrapper {
padding: 0;
}
.tb-select-map-entity-panel-buttons {
height: 40px;
display: flex;
flex-direction: row;
gap: 16px;
justify-content: flex-end;
align-items: flex-end;
}
}

69
ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.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 { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { UnplacedMapDataItem } from '@home/components/widget/lib/maps/data-layer/latest-map-data-layer';
@Component({
selector: 'tb-select-map-entity-panel',
templateUrl: './select-map-entity-panel.component.html',
providers: [],
styleUrls: ['./select-map-entity-panel.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class SelectMapEntityPanelComponent extends PageComponent implements OnInit {
@Input()
entities: UnplacedMapDataItem[];
@Output()
entitySelected = new EventEmitter<UnplacedMapDataItem>();
selectEntityFormGroup: UntypedFormGroup;
selectedEntity: UnplacedMapDataItem = null;
constructor(private fb: UntypedFormBuilder,
protected store: Store<AppState>,
private popover: TbPopoverComponent) {
super(store);
}
ngOnInit(): void {
this.selectEntityFormGroup = this.fb.group(
{
entity: ['', Validators.required]
}
);
this.popover.tbDestroy.subscribe(() => {
this.entitySelected.emit(this.selectedEntity);
});
}
cancel() {
this.popover.hide();
}
selectEntity() {
this.selectedEntity = this.selectEntityFormGroup.value.entity;
this.popover.hide();
}
}

2
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,

1
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/action-settings-button.component.html

@ -16,6 +16,7 @@
-->
<button #matButton [disabled]="disabled"
type="button"
class="tb-action-settings-button"
mat-stroked-button color="primary"
(click)="openActionSettingsPopup($event, matButton)">

7
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html

@ -21,7 +21,10 @@
<tb-widget-action
formControlName="widgetAction"
[callbacks]="callbacks"
[widgetType]="widgetType">
[widgetType]="widgetType"
[withName]="withName"
[actionNames]="actionNames"
[additionalWidgetActionTypes]="additionalWidgetActionTypes">
</tb-widget-action>
</div>
<div class="tb-action-settings-panel-buttons">
@ -36,7 +39,7 @@
type="button"
(click)="applyWidgetAction()"
[disabled]="widgetActionFormGroup.invalid || !widgetActionFormGroup.dirty">
{{ 'action.apply' | translate }}
{{ applyTitle }}
</button>
</div>
</div>

39
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<WidgetActionSettingsPanelComponent>;
@coerceBoolean()
withName = false;
@Input()
actionNames: string[];
@Input()
applyTitle = this.translate.instant('action.apply');
@Input()
additionalWidgetActionTypes: WidgetActionType[];
@Output()
widgetActionApplied = new EventEmitter<WidgetAction>();
@ -65,8 +62,8 @@ export class WidgetActionSettingsPanelComponent extends PageComponent implements
widgetActionFormGroup: UntypedFormGroup;
constructor(private fb: UntypedFormBuilder,
protected store: Store<AppState>) {
super(store);
private translate: TranslateService,
private popover: TbPopoverComponent) {
}
ngOnInit(): void {

14
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;

33
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html

@ -16,6 +16,23 @@
-->
<div class="tb-form-panel no-padding no-border" [formGroup]="widgetActionFormGroup">
<div *ngIf="withName" class="tb-form-row">
<div class="fixed-title-width">{{'widget-config.action-name' | translate}}*</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input required matInput formControlName="name" placeholder="{{ 'widget-config.set' | translate }}">
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="(widgetActionFormGroup.get('name').hasError('required')
? 'widget-config.action-name-required'
: 'widget-config.action-name-not-unique') | translate"
*ngIf="widgetActionFormGroup.get('name').invalid
&& (widgetActionFormGroup.get('name').touched || widgetActionFormGroup.get('name').hasError('actionNameNotUnique'))"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
</div>
<div class="tb-form-row">
<div translate>widget-config.action</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
@ -241,7 +258,21 @@
helpId="widget/action/custom_action_fn"
></tb-js-func>
</ng-template>
<ng-template [ngSwitchCase]="widgetActionType.customPretty">
<ng-template [ngSwitchCase]="widgetActionType.placeMapItem">
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'widget-action.map-item-type' | translate }}</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-select placeholder="{{ 'widget-config.set' | translate }}" formControlName="mapItemType">
@for(type of mapItemTypes; track type) {
<mat-option [value]="type">{{ mapItemTypeTranslationMap.get(type) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</ng-template>
<ng-template [ngSwitchCase]="widgetActionFormGroup.get('type').value === widgetActionType.customPretty
|| widgetActionFormGroup.get('type').value === widgetActionType.placeMapItem
? widgetActionFormGroup.get('type').value : ''">
<tb-custom-action-pretty-editor
formControlName="customAction">
</tb-custom-action-pretty-editor>

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save