Browse Source

UI: Map: image map.

pull/12723/head
Igor Kulikov 2 years ago
parent
commit
c0eda85e10
  1. 1
      application/src/main/data/json/system/widget_bundles/maps.json
  2. 55
      application/src/main/data/json/system/widget_types/image_map.json
  3. 102
      application/src/main/data/json/system/widget_types/image_map_deprecated.json
  4. 81
      application/src/main/data/json/system/widget_types/map.json
  5. 13
      ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.ts
  6. 15
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/image-map.ts
  7. 27
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts
  8. 23
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts
  9. 57
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts
  10. 33
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts
  11. 141
      ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts
  12. 293
      ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts
  13. 8
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts
  14. 18
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts
  15. 23
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss
  16. 224
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts
  17. 121
      ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts
  18. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.html
  19. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.ts
  20. 62
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.html
  21. 157
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.ts
  22. 11
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html
  23. 18
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts
  24. 62
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html
  25. 81
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts
  26. 4
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts
  27. 29
      ui-ngx/src/assets/locale/locale.constant-en_US.json

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

@ -10,6 +10,7 @@
},
"widgetTypeFqns": [
"map",
"image_map",
"maps_v2.openstreetmap",
"maps_v2.google_maps",
"maps_v2.image_map",

55
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

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

File diff suppressed because one or more lines are too long

13
ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.ts

@ -46,6 +46,19 @@ export class MapBasicConfigComponent extends BasicWidgetConfigComponent {
return this.mapWidgetConfigForm;
}
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 = [];
}
}
protected onConfigSet(configData: WidgetConfigComponentData) {
const settings: MapWidgetSettings = mergeDeepIgnoreArray<MapWidgetSettings>({} as MapWidgetSettings,
mapWidgetDefaultSettings, configData.config.settings as MapWidgetSettings);

15
ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/image-map.ts

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

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

@ -30,6 +30,7 @@ import { MapDataLayerType, TbDataLayerItem } from '@home/components/widget/lib/m
class TbCircleDataLayerItem extends TbDataLayerItem<CirclesDataLayerSettings, TbCirclesDataLayer> {
private circle: L.Circle;
private circleStyle: L.PathOptions;
constructor(data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[],
@ -41,10 +42,10 @@ class TbCircleDataLayerItem extends TbDataLayerItem<CirclesDataLayerSettings, Tb
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);
const style = this.dataLayer.getShapeStyle(data, dsData);
this.circleStyle = this.dataLayer.getShapeStyle(data, dsData);
this.circle = L.circle(center, {
radius: circleData.radius,
...style
...this.circleStyle
});
this.updateLabel(data, dsData);
return this.circle;
@ -63,7 +64,19 @@ class TbCircleDataLayerItem extends TbDataLayerItem<CirclesDataLayerSettings, Tb
.openTooltip(this.circle.getLatLng());
}
public update(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void {
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);
}
private updateCircleShape(data: FormattedData<TbMapDatasource>) {
const circleData = this.dataLayer.extractCircleCoordinates(data);
const center = new L.LatLng(circleData.latitude, circleData.longitude);
if (!this.circle.getLatLng().equals(center)) {
@ -72,10 +85,6 @@ class TbCircleDataLayerItem extends TbDataLayerItem<CirclesDataLayerSettings, Tb
if (this.circle.getRadius() !== circleData.radius) {
this.circle.setRadius(circleData.radius);
}
this.updateTooltip(data, dsData);
this.updateLabel(data, dsData);
const style = this.dataLayer.getShapeStyle(data, dsData);
this.circle.setStyle(style);
}
}
@ -95,8 +104,8 @@ export class TbCirclesDataLayer extends TbShapesDataLayer<CirclesDataLayerSettin
return datasource;
}
protected defaultBaseSettings(): Partial<CirclesDataLayerSettings> {
return defaultBaseCirclesDataLayerSettings;
protected defaultBaseSettings(map: TbMap<any>): Partial<CirclesDataLayerSettings> {
return defaultBaseCirclesDataLayerSettings(map.type());
}
protected doSetup(): Observable<void> {

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

@ -48,11 +48,13 @@ export abstract class TbDataLayerItem<S extends MapDataLayerSettings, D extends
protected layer: L;
protected tooltip: L.Popup;
protected data: FormattedData<TbMapDatasource>;
protected constructor(data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[],
protected settings: S,
protected dataLayer: D) {
this.data = data;
this.layer = this.create(data, dsData);
if (this.settings.tooltip?.show) {
this.createTooltip(data.$datasource);
@ -68,13 +70,24 @@ export abstract class TbDataLayerItem<S extends MapDataLayerSettings, D extends
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 createEventListeners(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void;
public abstract update(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void;
public invalidateCoordinates(): void {
this.doInvalidateCoordinates(this.data, this.dataLayer.getMap().getData());
}
public update(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void {
this.data = data;
this.doUpdate(data, dsData);
}
public remove() {
this.layer.off();
@ -237,7 +250,7 @@ export abstract class TbMapDataLayer<S extends MapDataLayerSettings, D extends T
protected constructor(protected map: TbMap<any>,
inputSettings: S) {
this.settings = mergeDeepIgnoreArray({} as S, this.defaultBaseSettings() as S, inputSettings);
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;
@ -325,6 +338,10 @@ export abstract class TbMapDataLayer<S extends MapDataLayerSettings, D extends T
}
}
public invalidateCoordinates(): void {
this.layerItems.forEach(item => item.invalidateCoordinates());
}
public getCtx(): WidgetContext {
return this.map.getCtx();
}
@ -349,7 +366,7 @@ export abstract class TbMapDataLayer<S extends MapDataLayerSettings, D extends T
public abstract dataLayerType(): MapDataLayerType;
protected abstract defaultBaseSettings(): Partial<S>;
protected abstract defaultBaseSettings(map: TbMap<any>): Partial<S>;
protected abstract doSetup(): Observable<any>;

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

@ -18,15 +18,18 @@ import {
BaseMarkerShapeSettings,
ClusterMarkerColorFunction,
DataLayerColorType,
defaultBaseMarkersDataLayerSettings, isValidLatLng,
defaultBaseMarkersDataLayerSettings,
isValidLatLng,
loadImageWithAspect,
MapStringFunction, MapType,
MapStringFunction,
MapType,
MarkerIconInfo,
MarkerIconSettings,
MarkerImageFunction,
MarkerImageInfo,
MarkerImageSettings,
MarkerImageType,
MarkerPositionFunction,
MarkersDataLayerSettings,
MarkerShapeSettings,
MarkerType,
@ -48,7 +51,12 @@ import {
} from '@home/components/widget/lib/maps/models/marker-shape.models';
import { MatIconRegistry } from '@angular/material/icon';
import { DomSanitizer } from '@angular/platform-browser';
import { MapDataLayerType, TbDataLayerItem, TbMapDataLayer } from '@home/components/widget/lib/maps/data-layer/map-data-layer';
import {
MapDataLayerType,
TbDataLayerItem,
TbMapDataLayer
} from '@home/components/widget/lib/maps/data-layer/map-data-layer';
import { TbImageMap } from '@home/components/widget/lib/maps/image-map';
class TbMarkerDataLayerItem extends TbDataLayerItem<MarkersDataLayerSettings, TbMarkersDataLayer, L.Marker> {
@ -64,7 +72,7 @@ class TbMarkerDataLayerItem extends TbDataLayerItem<MarkersDataLayerSettings, Tb
}
protected create(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L.Marker {
this.location = this.dataLayer.extractLocation(data);
this.location = this.dataLayer.extractLocation(data, dsData);
this.marker = L.marker(this.location, {
tbMarkerData: data
});
@ -86,15 +94,23 @@ class TbMarkerDataLayerItem extends TbDataLayerItem<MarkersDataLayerSettings, Tb
this.marker.bindTooltip(content, { className: 'tb-marker-label', permanent: true, direction: 'top', offset: this.labelOffset });
}
public update(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void {
const position = this.dataLayer.extractLocation(data);
protected doUpdate(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void {
this.marker.options.tbMarkerData = data;
this.updateMarkerPosition(data, dsData);
this.updateTooltip(data, dsData);
this.updateMarkerIcon(data, dsData);
}
protected doInvalidateCoordinates(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void {
this.updateMarkerPosition(data, dsData);
}
private updateMarkerPosition(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) {
const position = this.dataLayer.extractLocation(data, dsData);
if (!this.marker.getLatLng().equals(position)) {
this.location = position;
this.marker.setLatLng(position);
}
this.marker.options.tbMarkerData = data;
this.updateTooltip(data, dsData);
this.updateMarkerIcon(data, dsData);
}
private updateMarkerIcon(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) {
@ -310,6 +326,7 @@ export class TbMarkersDataLayer extends TbMapDataLayer<MarkersDataLayerSettings,
private markersClusterContainer: L.MarkerClusterGroup;
private clusterMarkerColorFunction: CompiledTbFunction<ClusterMarkerColorFunction>;
private positionFunction: CompiledTbFunction<MarkerPositionFunction>;
constructor(protected map: TbMap<any>,
inputSettings: MarkersDataLayerSettings) {
@ -333,8 +350,8 @@ export class TbMarkersDataLayer extends TbMapDataLayer<MarkersDataLayerSettings,
return datasource;
}
protected defaultBaseSettings(): Partial<MarkersDataLayerSettings> {
return defaultBaseMarkersDataLayerSettings;
protected defaultBaseSettings(map: TbMap<any>): Partial<MarkersDataLayerSettings> {
return defaultBaseMarkersDataLayerSettings(map.type());
}
protected doSetup(): Observable<void> {
@ -358,6 +375,16 @@ export class TbMarkersDataLayer extends TbMapDataLayer<MarkersDataLayerSettings,
)
);
}
if (this.map.type() === MapType.image) {
setup$.push(
parseTbFunction<MarkerPositionFunction>(this.getCtx().http, this.settings.positionFunction, ['origXPos', 'origYPos', 'data', 'dsData', 'aspect']).pipe(
map((parsed) => {
this.positionFunction = parsed;
return null;
})
)
);
}
setup$.push(this.markerIconProcessor.setup());
return forkJoin(setup$).pipe(map(() => null));
}
@ -496,9 +523,13 @@ export class TbMarkersDataLayer extends TbMapDataLayer<MarkersDataLayerSettings,
);
}
public extractLocation(data: FormattedData<TbMapDatasource>): L.LatLng {
const position = this.extractPosition(data);
public extractLocation(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L.LatLng {
let position = this.extractPosition(data);
if (position) {
if (this.map.type() === MapType.image && this.positionFunction) {
const imageMap = this.map as TbImageMap;
position = this.positionFunction.execute(position.x, position.y, data, dsData, imageMap.getAspect()) || {x: 0, y: 0};
}
return this.map.positionToLatLng(position);
} else {
return null;

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

@ -32,6 +32,7 @@ class TbPolygonDataLayerItem extends TbDataLayerItem<PolygonsDataLayerSettings,
private polygonContainer: L.FeatureGroup;
private polygon: L.Polygon;
private polygonStyle: L.PathOptions;
constructor(data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[],
@ -43,9 +44,9 @@ class TbPolygonDataLayerItem extends TbDataLayerItem<PolygonsDataLayerSettings,
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;
const style = this.dataLayer.getShapeStyle(data, dsData);
this.polygonStyle = this.dataLayer.getShapeStyle(data, dsData);
this.polygon = polyConstructor(polyData, {
...style
...this.polygonStyle
});
this.polygonContainer = L.featureGroup();
@ -68,14 +69,25 @@ class TbPolygonDataLayerItem extends TbDataLayerItem<PolygonsDataLayerSettings,
.openTooltip(this.polygonContainer.getBounds().getCenter());
}
public update(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void {
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);
this.polygon.setStyle(this.polygonStyle);
}
protected doInvalidateCoordinates(data: FormattedData<TbMapDatasource>, _dsData: FormattedData<TbMapDatasource>[]): void {
this.updatePolygonShape(data);
}
private updatePolygonShape(data: FormattedData<TbMapDatasource>) {
const polyData = this.dataLayer.extractPolygonCoordinates(data);
const style = this.dataLayer.getShapeStyle(data, dsData);
if (isCutPolygon(polyData) || polyData.length !== 2) {
if (this.polygon instanceof L.Rectangle) {
this.polygonContainer.removeLayer(this.polygon);
this.polygon = L.polygon(polyData, {
...style
...this.polygonStyle
});
this.polygon.addTo(this.polygonContainer);
} else {
@ -83,13 +95,10 @@ class TbPolygonDataLayerItem extends TbDataLayerItem<PolygonsDataLayerSettings,
}
} else if (polyData.length === 2) {
const bounds = new L.LatLngBounds(polyData);
// @ts-ignore
this.leafletPoly.setBounds(bounds);
(this.polygon as L.Rectangle).setBounds(bounds);
}
this.updateTooltip(data, dsData);
this.updateLabel(data, dsData);
this.polygon.setStyle(style);
}
}
export class TbPolygonsDataLayer extends TbShapesDataLayer<PolygonsDataLayerSettings, TbPolygonsDataLayer> {
@ -108,8 +117,8 @@ export class TbPolygonsDataLayer extends TbShapesDataLayer<PolygonsDataLayerSett
return datasource;
}
protected defaultBaseSettings(): Partial<PolygonsDataLayerSettings> {
return defaultBasePolygonsDataLayerSettings;
protected defaultBaseSettings(map: TbMap<any>): Partial<PolygonsDataLayerSettings> {
return defaultBasePolygonsDataLayerSettings(map.type());
}
protected doSetup(): Observable<any> {

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

@ -0,0 +1,141 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT 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
} from '@home/components/widget/lib/maps/models/map.models';
import { WidgetContext } from '@home/models/widget-component.models';
import { DeepPartial } from '@shared/models/common';
import { forkJoin, Observable, of } from 'rxjs';
import L, { LatLngBounds, LatLngTuple } 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: 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 positionToLatLng(position: {x: number; y: number}): L.LatLng {
return L.latLng(position.x, position.y) as L.LatLng;
}
public toPolygonCoordinates(expression: (LatLngTuple | LatLngTuple[] | LatLngTuple[][])[]): any {
return (expression).map((el) => {
if (!Array.isArray(el[0]) && el.length === 2) {
return el;
} else if (Array.isArray(el) && el.length) {
return this.toPolygonCoordinates(el as LatLngTuple[] | LatLngTuple[][]);
} else {
return null;
}
}).filter(el => !!el);
}
public convertCircleData(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;
}
}

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

@ -0,0 +1,293 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT 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 {
defaultImageMapSettings,
defaultImageMapSourceSettings,
ImageMapSettings,
imageMapSourceSettingsToDatasource,
ImageSourceType,
loadImageWithAspect,
MapZoomAction,
TbCircleData
} from '@home/components/widget/lib/maps/models/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, { LatLngBounds, LatLngLiteral, LatLngTuple } 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: LatLngBounds) {}
public positionToLatLng(position: {x: number; y: number}): L.LatLng {
return this.pointToLatLng(
position.x * this.width,
position.y * this.height);
}
public pointToLatLng(x: number, y: number): L.LatLng {
return L.CRS.Simple.pointToLatLng({ x, y } as L.PointExpression, this.maxZoom - 1);
}
private latLngToPoint(latLng: LatLngLiteral): L.Point {
return L.CRS.Simple.latLngToPoint(latLng, this.maxZoom - 1);
}
public toPolygonCoordinates(expression: (LatLngTuple | LatLngTuple[] | LatLngTuple[][])[]): any {
return (expression).map((el) => {
if (!Array.isArray(el[0]) && !Array.isArray(el[1]) && el.length === 2) {
return this.pointToLatLng(
el[0] * this.width,
el[1] * this.height
);
} else if (Array.isArray(el) && el.length) {
return this.toPolygonCoordinates(el as LatLngTuple[] | LatLngTuple[][]);
} else {
return null;
}
}).filter(el => !!el);
}
public convertCircleData(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 getAspect(): number {
return this.imageLayerData.aspect;
}
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))
);
})
);
}
}

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

@ -27,7 +27,11 @@ import {
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { mapWidgetDefaultSettings, MapWidgetSettings } from '@home/components/widget/lib/maps/map-widget.models';
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';
@ -88,7 +92,7 @@ export class MapWidgetComponent implements OnInit, OnDestroy {
const borderRadius = this.ctx.$widgetElement.css('borderRadius');
this.overlayStyle = {...this.overlayStyle, ...{borderRadius}};
this.cd.detectChanges();
this.map = TbMap.fromSettings(this.ctx, this.settings, this.mapElement.nativeElement);
this.map = createMap(this.ctx, this.settings, this.mapElement.nativeElement);
}
}

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

@ -14,9 +14,14 @@
/// limitations under the License.
///
import { defaultMapSettings, MapSetting } from '@home/components/widget/lib/maps/models/map.models';
import { defaultMapSettings, MapSetting, MapType } from '@home/components/widget/lib/maps/models/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;
@ -36,3 +41,14 @@ export const mapWidgetDefaultSettings: MapWidgetSettings =
},
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);
}
}

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

@ -50,15 +50,20 @@
border-bottom: none;
position: relative;
background: transparent;
&:hover {
border-bottom: none;
color: $tb-primary-color; // primary color
&:before {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background-color: rgba(0, 105, 92, 0.10);
&.leaflet-disabled {
color: rgba(0, 0, 0, 0.18);
}
&:not(.leaflet-disabled) {
&:hover {
border-bottom: none;
color: $tb-primary-color; // primary color
&:before {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background-color: rgba(0, 105, 92, 0.10);
}
}
}
&:first-child {

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

@ -17,16 +17,8 @@
import {
additionalMapDataSourcesToDatasources,
BaseMapSettings,
DEFAULT_ZOOM_LEVEL,
defaultGeoMapSettings,
defaultImageMapSettings,
GeoMapSettings,
ImageMapSettings,
latLngPointToBounds,
MapActionHandler,
MapSetting,
MapType,
MapZoomAction,
mergeMapDatasources,
parseCenterPosition,
TbCircleData,
@ -37,12 +29,11 @@ import { formattedDataFormDatasourceData, isDefinedAndNotNull, mergeDeepIgnoreAr
import { DeepPartial } from '@shared/models/common';
import L, { LatLngBounds, LatLngTuple, LeafletMouseEvent, Projection } from 'leaflet';
import { forkJoin, Observable, of } from 'rxjs';
import { TbMapLayer } from '@home/components/widget/lib/maps/map-layer';
import { map, switchMap, tap } from 'rxjs/operators';
import { switchMap } from 'rxjs/operators';
import '@home/components/widget/lib/maps/leaflet/leaflet-tb';
import { MapDataLayerType, TbMapDataLayer, } from '@home/components/widget/lib/maps/data-layer/map-data-layer';
import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models';
import { WidgetActionDescriptor, widgetType } from '@shared/models/widget.models';
import { FormattedData, WidgetActionDescriptor, widgetType } from '@shared/models/widget.models';
import { EntityDataPageLink } from '@shared/models/query/query.models';
import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe';
import { TbMarkersDataLayer } from '@home/components/widget/lib/maps/data-layer/markers-data-layer';
@ -52,17 +43,6 @@ import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance;
export abstract class TbMap<S extends BaseMapSettings> {
static fromSettings(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);
}
}
protected settings: S;
protected map: L.Map;
@ -73,6 +53,7 @@ export abstract class TbMap<S extends BaseMapSettings> {
protected northEast = new L.LatLng(Projection.SphericalMercator['MAX_LATITUDE'], 180);
protected dataLayers: TbMapDataLayer<any,any>[];
protected dsData: FormattedData<TbMapDatasource>[];
protected mapElement: HTMLElement;
@ -122,7 +103,9 @@ export abstract class TbMap<S extends BaseMapSettings> {
}
private setupControls(): Observable<any> {
this.map.zoomControl.setPosition(this.settings.controlsPosition);
if (this.map.zoomControl) {
this.map.zoomControl.setPosition(this.settings.controlsPosition);
}
return this.doSetupControls();
}
@ -288,9 +271,9 @@ export abstract class TbMap<S extends BaseMapSettings> {
}
private update(subscription: IWidgetSubscription) {
const dsData = formattedDataFormDatasourceData<TbMapDatasource>(subscription.data,
this.dsData = formattedDataFormDatasourceData<TbMapDatasource>(subscription.data,
undefined, undefined, el => el.datasource.entityId + el.datasource.mapDataIds[0]);
this.dataLayers.forEach(dl => dl.updateData(dsData));
this.dataLayers.forEach(dl => dl.updateData(this.dsData));
this.updateBounds();
}
@ -306,7 +289,6 @@ export abstract class TbMap<S extends BaseMapSettings> {
if (dataLayersBounds.length) {
bounds = new L.LatLngBounds(null, null);
dataLayersBounds.forEach(b => bounds.extend(b));
const mapBounds = this.map.getBounds();
if (bounds.isValid() && this.settings.fitMapBounds && !mapBounds.contains(bounds)) {
if (!this.ignoreUpdateBounds) {
@ -317,35 +299,6 @@ export abstract class TbMap<S extends BaseMapSettings> {
}
}
private fitBounds(bounds: 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();
}
}
}
private loadActions(name: string): { [name: string]: MapActionHandler } {
const descriptors = this.ctx.actionsApi.getActionDescriptors(name);
const actions: { [name: string]: MapActionHandler } = {};
@ -373,10 +326,16 @@ export abstract class TbMap<S extends BaseMapSettings> {
protected abstract onResize(): void;
protected abstract fitBounds(bounds: LatLngBounds): void;
protected doSetupControls(): Observable<any> {
return of(null);
}
protected invalidateDataLayersCoordinates(): void {
this.dataLayers.forEach(dl => dl.invalidateCoordinates());
}
protected getSidebar(): L.TB.SidebarControl {
if (!this.sidebar) {
this.sidebar = L.TB.sidebar({
@ -392,6 +351,10 @@ export abstract class TbMap<S extends BaseMapSettings> {
return this.ctx;
}
public getData(): FormattedData<TbMapDatasource>[] {
return this.dsData;
}
public getMap(): L.Map {
return this.map;
}
@ -459,154 +422,3 @@ export abstract class TbMap<S extends BaseMapSettings> {
public abstract convertCircleData(circle: TbCircleData): TbCircleData;
}
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 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 positionToLatLng(position: {x: number; y: number}): L.LatLng {
return L.latLng(position.x, position.y) as L.LatLng;
}
public toPolygonCoordinates(expression: (LatLngTuple | LatLngTuple[] | LatLngTuple[][])[]): any {
return (expression).map((el) => {
if (!Array.isArray(el[0]) && el.length === 2) {
return el;
} else if (Array.isArray(el) && el.length) {
return this.toPolygonCoordinates(el as LatLngTuple[] | LatLngTuple[][]);
} else {
return null;
}
}).filter(el => !!el);
}
public convertCircleData(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;
}
}
class TbImageMap extends TbMap<ImageMapSettings> {
private maxZoom = 4;
private width = 0;
private height = 0;
constructor(protected ctx: WidgetContext,
protected inputSettings: DeepPartial<ImageMapSettings>,
protected mapElement: HTMLElement) {
super(ctx, inputSettings, mapElement);
}
protected defaultSettings(): ImageMapSettings {
return defaultImageMapSettings;
}
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),
minZoom: 1,
maxZoom: this.maxZoom,
zoom: 1,
crs: L.CRS.Simple,
attributionControl: false
}).setView(this.defaultCenterPosition, this.settings.defaultZoomLevel || DEFAULT_ZOOM_LEVEL);
return of(map);
}
protected onResize(): void {}
public positionToLatLng(position: {x: number; y: number}): L.LatLng {
return this.pointToLatLng(
position.x * this.width,
position.y * this.height);
}
public pointToLatLng(x: number, y: number): L.LatLng {
return L.CRS.Simple.pointToLatLng({ x, y } as L.PointExpression, this.maxZoom - 1);
}
public toPolygonCoordinates(expression: (LatLngTuple | LatLngTuple[] | LatLngTuple[][])[]): any {
return (expression).map((el) => {
if (!Array.isArray(el[0]) && !Array.isArray(el[1]) && el.length === 2) {
return this.pointToLatLng(
el[0] * this.width,
el[1] * this.height
);
} else if (Array.isArray(el) && el.length) {
return this.toPolygonCoordinates(el as LatLngTuple[] | LatLngTuple[][]);
} else {
return null;
}
}).filter(el => !!el);
}
public convertCircleData(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;
}
}

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

@ -16,7 +16,7 @@
import { DataKey, Datasource, DatasourceType, FormattedData } from '@shared/models/widget.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { guid, hashCode, isDefinedAndNotNull, isString, mergeDeep } from '@core/utils';
import { guid, hashCode, isDefinedAndNotNull, isNotEmptyStr, isString, mergeDeep } from '@core/utils';
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
import { materialColors } from '@shared/models/material.models';
import L from 'leaflet';
@ -96,7 +96,7 @@ export interface MapDataLayerSettings extends MapDataSourceSettings {
groups?: string[];
}
export const defaultBaseDataLayerSettings: Partial<MapDataLayerSettings> = {
export const defaultBaseDataLayerSettings = (mapType: MapType): Partial<MapDataLayerSettings> => ({
label: {
show: true,
type: DataLayerPatternType.pattern,
@ -107,11 +107,13 @@ export const defaultBaseDataLayerSettings: Partial<MapDataLayerSettings> = {
trigger: DataLayerTooltipTrigger.click,
autoclose: true,
type: DataLayerPatternType.pattern,
pattern: '<b>${entityName}</b><br/><br/><b>Latitude:</b> ${latitude:7}<br/><b>Longitude:</b> ${longitude:7}<br/><b>Temperature:</b> ${temperature} °C<br/><small>See tooltip settings for details</small>',
pattern: mapType === MapType.geoMap ?
'<b>${entityName}</b><br/><br/><b>Latitude:</b> ${latitude:7}<br/><b>Longitude:</b> ${longitude:7}<br/><b>Temperature:</b> ${temperature} °C<br/><small>See tooltip settings for details</small>'
: '<b>${entityName}</b><br/><br/><b>X Pos:</b> ${xPos:2}<br/><b>Y Pos:</b> ${yPos:2}<br/><b>Temperature:</b> ${temperature} °C<br/><small>See tooltip settings for details</small>',
offsetX: 0,
offsetY: -1
}
}
})
export type MapDataLayerType = 'markers' | 'polygons' | 'circles';
@ -235,6 +237,7 @@ export interface MarkersDataLayerSettings extends MapDataLayerSettings {
markerImage?: MarkerImageSettings;
markerOffsetX: number;
markerOffsetY: number;
positionFunction?: TbFunction;
markerClustering: MarkerClusteringSettings;
}
@ -281,9 +284,9 @@ export const defaultMarkersDataLayerSettings = (mapType: MapType, functionsOnly
settings: {},
color: materialColors[0].value
}
} as MarkersDataLayerSettings, defaultBaseMarkersDataLayerSettings as MarkersDataLayerSettings);
} as MarkersDataLayerSettings, defaultBaseMarkersDataLayerSettings(mapType) as MarkersDataLayerSettings);
export const defaultBaseMarkersDataLayerSettings: Partial<MarkersDataLayerSettings> = mergeDeep({
export const defaultBaseMarkersDataLayerSettings = (mapType: MapType): Partial<MarkersDataLayerSettings> => mergeDeep({
markerType: MarkerType.shape,
markerShape: {
shape: MarkerShape.markerShape1,
@ -308,6 +311,7 @@ export const defaultBaseMarkersDataLayerSettings: Partial<MarkersDataLayerSettin
},
markerOffsetX: 0.5,
markerOffsetY: 1,
positionFunction: 'return {x: origXPos, y: origYPos};',
markerClustering: {
enable: false,
zoomOnClick: true,
@ -321,7 +325,7 @@ export const defaultBaseMarkersDataLayerSettings: Partial<MarkersDataLayerSettin
useClusterMarkerColorFunction: false,
clusterMarkerColorFunction: null
}
} as MarkersDataLayerSettings, defaultBaseDataLayerSettings);
} as MarkersDataLayerSettings, defaultBaseDataLayerSettings(mapType));
export interface ShapeDataLayerSettings extends MapDataLayerSettings {
fillColor: DataLayerColorSettings;
@ -333,7 +337,7 @@ export interface PolygonsDataLayerSettings extends ShapeDataLayerSettings {
polygonKey: DataKey;
}
export const defaultPolygonsDataLayerSettings = (functionsOnly = false): PolygonsDataLayerSettings => mergeDeep({
export const defaultPolygonsDataLayerSettings = (mapType: MapType, functionsOnly = false): PolygonsDataLayerSettings => mergeDeep({
dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity,
dsLabel: functionsOnly ? 'First polygon' : '',
polygonKey: {
@ -343,9 +347,9 @@ export const defaultPolygonsDataLayerSettings = (functionsOnly = false): Polygon
settings: {},
color: materialColors[0].value
}
} as PolygonsDataLayerSettings, defaultBasePolygonsDataLayerSettings as PolygonsDataLayerSettings);
} as PolygonsDataLayerSettings, defaultBasePolygonsDataLayerSettings(mapType) as PolygonsDataLayerSettings);
export const defaultBasePolygonsDataLayerSettings: Partial<PolygonsDataLayerSettings> = mergeDeep({
export const defaultBasePolygonsDataLayerSettings = (mapType: MapType): Partial<PolygonsDataLayerSettings> => mergeDeep({
fillColor: {
type: DataLayerColorType.constant,
color: 'rgba(51,136,255,0.2)',
@ -355,14 +359,14 @@ export const defaultBasePolygonsDataLayerSettings: Partial<PolygonsDataLayerSett
color: '#3388ff',
},
strokeWeight: 3
} as Partial<PolygonsDataLayerSettings>, defaultBaseDataLayerSettings,
} as Partial<PolygonsDataLayerSettings>, defaultBaseDataLayerSettings(mapType),
{label: {show: false}, tooltip: {show: false, pattern: '<b>${entityName}</b><br/><br/><b>TimeStamp:</b> ${ts:7}'}} as Partial<PolygonsDataLayerSettings>)
export interface CirclesDataLayerSettings extends ShapeDataLayerSettings {
circleKey: DataKey;
}
export const defaultCirclesDataLayerSettings = (functionsOnly = false): CirclesDataLayerSettings => mergeDeep({
export const defaultCirclesDataLayerSettings = (mapType: MapType, functionsOnly = false): CirclesDataLayerSettings => mergeDeep({
dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity,
dsLabel: functionsOnly ? 'First circle' : '',
circleKey: {
@ -372,9 +376,9 @@ export const defaultCirclesDataLayerSettings = (functionsOnly = false): CirclesD
settings: {},
color: materialColors[0].value
}
} as CirclesDataLayerSettings, defaultBaseCirclesDataLayerSettings as CirclesDataLayerSettings);
} as CirclesDataLayerSettings, defaultBaseCirclesDataLayerSettings(mapType) as CirclesDataLayerSettings);
export const defaultBaseCirclesDataLayerSettings: Partial<CirclesDataLayerSettings> = mergeDeep({
export const defaultBaseCirclesDataLayerSettings = (mapType: MapType): Partial<CirclesDataLayerSettings> => mergeDeep({
fillColor: {
type: DataLayerColorType.constant,
color: 'rgba(51,136,255,0.2)',
@ -384,7 +388,7 @@ export const defaultBaseCirclesDataLayerSettings: Partial<CirclesDataLayerSettin
color: '#3388ff',
},
strokeWeight: 3
} as Partial<CirclesDataLayerSettings>, defaultBaseDataLayerSettings,
} as Partial<CirclesDataLayerSettings>, defaultBaseDataLayerSettings(mapType),
{label: {show: false}, tooltip: {show: false, pattern: '<b>${entityName}</b><br/><br/><b>TimeStamp:</b> ${ts:7}'}} as Partial<CirclesDataLayerSettings>)
export const defaultMapDataLayerSettings = (mapType: MapType, dataLayerType: MapDataLayerType, functionsOnly = false): MapDataLayerSettings => {
@ -392,20 +396,20 @@ export const defaultMapDataLayerSettings = (mapType: MapType, dataLayerType: Map
case 'markers':
return defaultMarkersDataLayerSettings(mapType, functionsOnly);
case 'polygons':
return defaultPolygonsDataLayerSettings(functionsOnly);
return defaultPolygonsDataLayerSettings(mapType, functionsOnly);
case 'circles':
return defaultCirclesDataLayerSettings(functionsOnly);
return defaultCirclesDataLayerSettings(mapType, functionsOnly);
}
};
export const defaultBaseMapDataLayerSettings = <T extends MapDataLayerSettings>(dataLayerType: MapDataLayerType): T => {
export const defaultBaseMapDataLayerSettings = <T extends MapDataLayerSettings>(mapType: MapType, dataLayerType: MapDataLayerType): T => {
switch (dataLayerType) {
case 'markers':
return defaultBaseMarkersDataLayerSettings as T;
return defaultBaseMarkersDataLayerSettings(mapType) as T;
case 'polygons':
return defaultBasePolygonsDataLayerSettings as T;
return defaultBasePolygonsDataLayerSettings(mapType) as T;
case 'circles':
return defaultBaseCirclesDataLayerSettings as T;
return defaultBaseCirclesDataLayerSettings(mapType) as T;
}
}
@ -453,12 +457,33 @@ export enum MapControlsPosition {
bottomright = 'bottomright'
}
export const mapControlPositions = Object.keys(MapControlsPosition) as MapControlsPosition[];
export const mapControlsPositionTranslationMap = new Map<MapControlsPosition, string>(
[
[MapControlsPosition.topleft, 'widgets.maps.control.position-topleft'],
[MapControlsPosition.topright, 'widgets.maps.control.position-topright'],
[MapControlsPosition.bottomleft, 'widgets.maps.control.position-bottomleft'],
[MapControlsPosition.bottomright, 'widgets.maps.control.position-bottomright']
]
);
export enum MapZoomAction {
scroll = 'scroll',
doubleClick = 'doubleClick',
controlButtons = 'controlButtons'
}
export const mapZoomActions = Object.keys(MapZoomAction) as MapZoomAction[];
export const mapZoomActionTranslationMap = new Map<MapZoomAction, string>(
[
[MapZoomAction.scroll, 'widgets.maps.control.zoom-scroll'],
[MapZoomAction.doubleClick, 'widgets.maps.control.zoom-double-click'],
[MapZoomAction.controlButtons, 'widgets.maps.control.zoom-control-buttons']
]
);
export interface BaseMapSettings {
mapType: MapType;
markers: MarkersDataLayerSettings[];
@ -756,20 +781,57 @@ export const defaultGeoMapSettings: GeoMapSettings = {
export enum ImageSourceType {
image = 'image',
attribute = 'attribute'
entityKey = 'entityKey'
}
export interface ImageMapSourceSettings {
sourceType: ImageSourceType;
url?: string;
entityAliasId?: string;
entityKey?: DataKey;
}
export const imageMapSourceSettingsValid = (imageSource: ImageMapSourceSettings): boolean => {
if (!imageSource?.sourceType) {
return false;
} else if (imageSource.sourceType === ImageSourceType.image) {
return isNotEmptyStr(imageSource.url);
} else {
return isNotEmptyStr(imageSource.entityAliasId) && !!imageSource.entityKey;
}
}
export const imageMapSourceSettingsValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
const imageSource: ImageMapSourceSettings = control.value;
if (!imageMapSourceSettingsValid(imageSource)) {
return {
imageMapSource: true
};
}
return null;
};
export const defaultImageMapSourceSettings: ImageMapSourceSettings = {
sourceType: ImageSourceType.image,
url: 'data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==',
};
export const imageMapSourceSettingsToDatasource = (settings: ImageMapSourceSettings): Datasource => {
return {
type: DatasourceType.entity,
name: '',
entityAliasId: settings.entityAliasId,
dataKeys: [settings.entityKey]
};
};
export interface ImageMapSettings extends BaseMapSettings {
imageSourceType?: ImageSourceType;
imageUrl?: string;
imageEntityAlias?: string;
imageUrlAttribute?: string;
imageSource?: ImageMapSourceSettings;
}
export const defaultImageMapSettings: ImageMapSettings = {
mapType: MapType.image,
imageSourceType: ImageSourceType.image,
imageUrl: 'data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==',
imageSource: mergeDeep({} as ImageMapSourceSettings, defaultImageMapSourceSettings),
...mergeDeep({} as BaseMapSettings, defaultBaseMapSettings)
}
@ -799,6 +861,9 @@ export type MarkerImageFunction = (data: FormattedData<TbMapDatasource>, markerI
export type ClusterMarkerColorFunction = (data: FormattedData<TbMapDatasource>[], childCount: number) => string;
export type MarkerPositionFunction = (origXPos: number, origYPos: number, data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[], aspect: number) => { x: number, y: number };
export interface TbCircleData {
latitude: number;
longitude: number;

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.html

@ -15,7 +15,7 @@
limitations under the License.
-->
<mat-form-field class="tb-key-field"
<mat-form-field class="tb-key-field mat-block"
[class]="{'tb-inline-field': inlineField,
'tb-suffix-absolute': (inlineField && !keysFormControl.value?.length)}"
[appearance]="inlineField ? 'fill' : appearance"

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.ts

@ -197,7 +197,7 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, OnChange
deviceId: string;
@Input()
generateKey: (key: DataKey) => DataKey = (key) => key;
generateKey: (key: DataKey) => DataKey;
private requiredValue: boolean;
get required(): boolean {

62
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.html

@ -0,0 +1,62 @@
<!--
Copyright © 2016-2024 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT 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-image-map-source-settings tb-form-panel stroked" [formGroup]="imageMapSourceFormGroup">
<div class="flex flex-row items-center justify-between">
<div class="tb-form-panel-title">
{{ 'widgets.maps.image.image-source' | translate }}
</div>
<tb-toggle-select formControlName="sourceType">
<tb-toggle-option [value]="ImageSourceType.image">{{ 'widgets.maps.image.image-source-image' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="ImageSourceType.entityKey">{{ 'widgets.maps.image.image-source-entity-key' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<tb-gallery-image-input *ngIf="ImageSourceType.image === imageMapSourceFormGroup.get('sourceType').value"
required
formControlName="url">
</tb-gallery-image-input>
<ng-container *ngIf="ImageSourceType.entityKey === imageMapSourceFormGroup.get('sourceType').value">
<div class="tb-form-row column-xs">
<div class="fixed-title-width" translate>widgets.maps.image.source-entity-alias</div>
<tb-entity-alias-select
class="flex-1"
tbRequired
inlineField
[aliasController]="context.aliasController"
formControlName="entityAliasId"
[callbacks]="context.callbacks">
</tb-entity-alias-select>
</div>
<div class="tb-form-row tb-standard-fields column-xs">
<div class="fixed-title-width" translate>widgets.maps.image.image-url-key</div>
<tb-data-key-input
class="flex-1"
required
requiredText="widgets.maps.image.image-url-key-required"
[datasourceType]="DatasourceType.entity"
[entityAliasId]="imageMapSourceFormGroup.get('entityAliasId').value"
[aliasController]="context.aliasController"
[widgetType]="widgetType.latest"
[dataKeyTypes]="[DataKeyType.attribute, DataKeyType.timeseries]"
[callbacks]="context.callbacks"
[generateKey]="context.generateDataKey"
(keyEdit)="editKey()"
formControlName="entityKey">
</tb-data-key-input>
</div>
</ng-container>
</div>

157
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.ts

@ -0,0 +1,157 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, DestroyRef, forwardRef, Input, OnInit, ViewEncapsulation } from '@angular/core';
import {
ControlValueAccessor,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
Validator,
Validators
} from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ImageMapSourceSettings, ImageSourceType } from '@home/components/widget/lib/maps/models/map.models';
import { DataKey, DatasourceType, widgetType } from '@shared/models/widget.models';
import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
@Component({
selector: 'tb-image-map-source-settings',
templateUrl: './image-map-source-settings.component.html',
styleUrls: [],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ImageMapSourceSettingsComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => ImageMapSourceSettingsComponent),
multi: true
}
]
})
export class ImageMapSourceSettingsComponent implements OnInit, ControlValueAccessor, Validator {
ImageSourceType = ImageSourceType;
DatasourceType = DatasourceType;
widgetType = widgetType;
DataKeyType = DataKeyType;
@Input()
disabled: boolean;
@Input()
context: MapSettingsContext;
private modelValue: ImageMapSourceSettings;
private propagateChange = null;
public imageMapSourceFormGroup: UntypedFormGroup;
constructor(private fb: UntypedFormBuilder,
private destroyRef: DestroyRef) {
}
ngOnInit(): void {
this.imageMapSourceFormGroup = this.fb.group({
sourceType: [null, [Validators.required]],
url: [null, [Validators.required]],
entityAliasId: [null, [Validators.required]],
entityKey: [null, [Validators.required]]
});
this.imageMapSourceFormGroup.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.updateModel();
});
this.imageMapSourceFormGroup.get('sourceType').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.updateValidators();
});
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(_fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (isDisabled) {
this.imageMapSourceFormGroup.disable({emitEvent: false});
} else {
this.imageMapSourceFormGroup.enable({emitEvent: false});
this.updateValidators();
}
}
writeValue(value: ImageMapSourceSettings): void {
this.modelValue = value;
this.imageMapSourceFormGroup.patchValue(
value, {emitEvent: false}
);
this.updateValidators();
}
editKey() {
const entityKey: DataKey = this.imageMapSourceFormGroup.get('entityKey').value;
this.context.editKey(entityKey,
null, this.imageMapSourceFormGroup.get('entityAliasId').value).subscribe(
(updatedDataKey) => {
if (updatedDataKey) {
this.imageMapSourceFormGroup.get('entityKey').patchValue(updatedDataKey);
}
}
);
}
public validate(c: UntypedFormControl) {
const valid = this.imageMapSourceFormGroup.valid;
return valid ? null : {
imageMapSource: {
valid: false,
},
};
}
private updateValidators() {
const sourceType: ImageSourceType = this.imageMapSourceFormGroup.get('sourceType').value;
if (sourceType === ImageSourceType.image) {
this.imageMapSourceFormGroup.get('url').enable({emitEvent: false});
this.imageMapSourceFormGroup.get('entityAliasId').disable({emitEvent: false});
this.imageMapSourceFormGroup.get('entityKey').disable({emitEvent: false});
} else {
this.imageMapSourceFormGroup.get('url').disable({emitEvent: false});
this.imageMapSourceFormGroup.get('entityAliasId').enable({emitEvent: false});
this.imageMapSourceFormGroup.get('entityKey').enable({emitEvent: false});
}
}
private updateModel() {
this.modelValue = this.imageMapSourceFormGroup.getRawValue();
this.propagateChange(this.modelValue);
}
}

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

@ -214,6 +214,17 @@
</mat-form-field>
</div>
</div>
<div *ngIf="mapType === MapType.image" class="tb-form-panel stroked">
<div class="tb-form-panel-title" translate>widgets.maps.data-layer.marker.position-conversion</div>
<tb-js-func formControlName="positionFunction"
withModules
minHeight="100px"
[globalVariables]="functionScopeVariables"
[functionArgs]="['origXPos', 'origYPos', 'data', 'dsData', 'aspect']"
functionTitle="{{ 'widgets.maps.data-layer.marker.position-conversion-function' | translate }}"
helpId="widget/lib/map/position_fn">
</tb-js-func>
</div>
</ng-container>
<ng-container *ngIf="['polygons', 'circles'].includes(dataLayerType)">
<div class="tb-form-row space-between">

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

@ -17,13 +17,15 @@
import { Component, DestroyRef, Inject, ViewEncapsulation } from '@angular/core';
import { DialogComponent } from '@shared/components/dialog.component';
import {
CirclesDataLayerSettings, defaultBaseMapDataLayerSettings,
CirclesDataLayerSettings,
defaultBaseMapDataLayerSettings,
MapDataLayerSettings,
MapDataLayerType,
MapType,
MarkersDataLayerSettings,
MarkerType,
PolygonsDataLayerSettings, ShapeDataLayerSettings
PolygonsDataLayerSettings,
ShapeDataLayerSettings
} from '@home/components/widget/lib/maps/models/map.models';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
@ -36,6 +38,8 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EntityType } from '@shared/models/entity-type.models';
import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models';
import { genNextLabelForDataKeys, mergeDeepIgnoreArray } from '@core/utils';
import { MapProviders } from '@home/components/widget/lib/maps-legacy/map-models';
import { WidgetService } from '@core/http/widget.service';
export interface MapDataLayerDialogData {
settings: MapDataLayerSettings;
@ -76,6 +80,8 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
generateAdditionalDataKey = this.generateDataKey.bind(this);
functionScopeVariables = this.widgetService.getWidgetScopeVariables();
dialogTitle: string;
constructor(protected store: Store<AppState>,
@ -83,6 +89,7 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
@Inject(MAT_DIALOG_DATA) public data: MapDataLayerDialogData,
public dialogRef: MatDialogRef<MapDataLayerDialogComponent, MapDataLayerSettings>,
private fb: FormBuilder,
private widgetService: WidgetService,
private destroyRef: DestroyRef) {
super(store, router, dialogRef);
@ -93,7 +100,7 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
}
this.settings = mergeDeepIgnoreArray({} as MapDataLayerSettings,
defaultBaseMapDataLayerSettings<MapDataLayerSettings>(this.dataLayerType), this.settings);
defaultBaseMapDataLayerSettings<MapDataLayerSettings>(this.mapType, this.dataLayerType), this.settings);
this.dataLayerFormGroup = this.fb.group({
dsType: [this.settings.dsType, [Validators.required]],
@ -119,6 +126,9 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
this.dataLayerFormGroup.addControl('markerImage', this.fb.control(markersDataLayer.markerImage, Validators.required));
this.dataLayerFormGroup.addControl('markerOffsetX', this.fb.control(markersDataLayer.markerOffsetX));
this.dataLayerFormGroup.addControl('markerOffsetY', this.fb.control(markersDataLayer.markerOffsetY));
if (this.mapType === MapType.image) {
this.dataLayerFormGroup.addControl('positionFunction', this.fb.control(markersDataLayer.positionFunction));
}
this.dataLayerFormGroup.addControl('markerClustering', this.fb.control(markersDataLayer.markerClustering));
this.dataLayerFormGroup.get('markerType').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
@ -292,4 +302,6 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
const settings: MapDataLayerSettings = this.dataLayerFormGroup.getRawValue();
this.dialogRef.close(settings);
}
protected readonly mapProvider = MapProviders;
}

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

@ -27,7 +27,12 @@
</tb-toggle-select>
</div>
<tb-map-layers *ngIf="MapType.geoMap === mapSettingsFormGroup.get('mapType').value"
formControlName="layers"></tb-map-layers>
formControlName="layers">
</tb-map-layers>
<tb-image-map-source-settings *ngIf="MapType.image === mapSettingsFormGroup.get('mapType').value"
[context]="context"
formControlName="imageSource">
</tb-image-map-source-settings>
</div>
<div class="tb-form-panel">
<div class="flex flex-row items-center justify-between">
@ -65,4 +70,59 @@
[context]="context">
</tb-map-data-sources>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title">
{{ 'widgets.maps.control.map-controls' | translate }}
</div>
<div class="tb-form-row space-between column-xs">
<div translate>widgets.maps.control.position</div>
<mat-form-field class="medium-width" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="controlsPosition">
<mat-option *ngFor="let position of mapControlPositions" [value]="position">
{{ mapControlsPositionTranslationMap.get(position) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row space-between column-lt-md">
<div translate>widgets.maps.control.zoom-actions</div>
<mat-chip-listbox multiple formControlName="zoomActions">
<mat-chip-option *ngFor="let action of mapZoomActions" [value]="action">
{{ mapZoomActionTranslationMap.get(action) | translate }}
</mat-chip-option>
</mat-chip-listbox>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title">
{{ 'widgets.maps.common.common-map-settings' | translate }}
</div>
<ng-container *ngIf="MapType.geoMap === mapSettingsFormGroup.get('mapType').value">
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="fitMapBounds">
{{ 'widgets.maps.common.fit-map-bounds' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row space-between column-xs">
<mat-slide-toggle class="mat-slide" formControlName="useDefaultCenterPosition">
{{ 'widgets.maps.common.default-map-center-position' | translate }}
</mat-slide-toggle>
<mat-form-field appearance="outline" class="medium-width" subscriptSizing="dynamic">
<input matInput formControlName="defaultCenterPosition" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
<div class="tb-form-row space-between">
<div translate>widgets.maps.common.default-map-zoom-level</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput type="number" min="0" max="20" step="1" formControlName="defaultZoomLevel" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</ng-container>
<div class="tb-form-row space-between">
<div translate>widgets.maps.common.entities-limit</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput type="number" min="1" step="1" formControlName="mapPageSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
</ng-container>

81
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts

@ -22,9 +22,20 @@ import {
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
Validator
Validator,
Validators
} from '@angular/forms';
import { ImageSourceType, MapDataLayerType, MapSetting, MapType } from '@home/components/widget/lib/maps/models/map.models';
import {
defaultImageMapSourceSettings,
ImageMapSourceSettings, imageMapSourceSettingsValidator,
mapControlPositions,
mapControlsPositionTranslationMap,
MapDataLayerType,
MapSetting,
MapType,
mapZoomActions,
mapZoomActionTranslationMap
} from '@home/components/widget/lib/maps/models/map.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { merge, Observable } from 'rxjs';
import { coerceBoolean } from '@shared/decorators/coercion';
@ -36,7 +47,7 @@ import {
DataKeyConfigDialogComponent,
DataKeyConfigDialogData
} from '@home/components/widget/lib/settings/common/key/data-key-config-dialog.component';
import { deepClone } from '@core/utils';
import { deepClone, mergeDeep } from '@core/utils';
import { MatDialog } from '@angular/material/dialog';
@Component({
@ -58,6 +69,14 @@ import { MatDialog } from '@angular/material/dialog';
})
export class MapSettingsComponent implements OnInit, ControlValueAccessor, Validator {
mapControlPositions = mapControlPositions;
mapZoomActions = mapZoomActions;
mapControlsPositionTranslationMap = mapControlsPositionTranslationMap;
mapZoomActionTranslationMap = mapZoomActionTranslationMap;
MapType = MapType;
@Input()
@ -105,10 +124,7 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid
this.mapSettingsFormGroup = this.fb.group({
mapType: [null, []],
layers: [null, []],
imageSourceType: [null, []],
imageUrl: [null, []],
imageEntityAlias: [null, []],
imageUrlAttribute: [null, []],
imageSource: [null, [imageMapSourceSettingsValidator]],
markers: [null, []],
polygons: [null, []],
circles: [null, []],
@ -118,8 +134,8 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid
fitMapBounds: [null, []],
useDefaultCenterPosition: [null, []],
defaultCenterPosition: [null, []],
defaultZoomLevel: [null, []],
mapPageSize: [null, []]
defaultZoomLevel: [null, [Validators.min(0), Validators.max(20)]],
mapPageSize: [null, [Validators.min(1), Validators.required]]
});
this.mapSettingsFormGroup.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
@ -127,12 +143,17 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid
this.updateModel();
});
merge(this.mapSettingsFormGroup.get('mapType').valueChanges,
this.mapSettingsFormGroup.get('imageSourceType').valueChanges
this.mapSettingsFormGroup.get('useDefaultCenterPosition').valueChanges
).pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.updateValidators();
});
this.mapSettingsFormGroup.get('mapType').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe((mapType: MapType) => {
this.mapTypeChanged(mapType);
});
}
registerOnChange(fn: any): void {
@ -160,7 +181,7 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid
this.updateValidators();
}
public validate(c: UntypedFormControl) {
public validate(_c: UntypedFormControl) {
const valid = this.mapSettingsFormGroup.valid;
return valid ? null : {
mapSettings: {
@ -171,24 +192,34 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid
private updateValidators() {
const mapType: MapType = this.mapSettingsFormGroup.get('mapType').value;
const imageSourceType: ImageSourceType = this.mapSettingsFormGroup.get('imageSourceType').value;
if (mapType === MapType.geoMap) {
this.mapSettingsFormGroup.get('layers').enable({emitEvent: false});
this.mapSettingsFormGroup.get('imageSourceType').disable({emitEvent: false});
this.mapSettingsFormGroup.get('imageUrl').disable({emitEvent: false});
this.mapSettingsFormGroup.get('imageEntityAlias').disable({emitEvent: false});
this.mapSettingsFormGroup.get('imageUrlAttribute').disable({emitEvent: false});
this.mapSettingsFormGroup.get('imageSource').disable({emitEvent: false});
this.mapSettingsFormGroup.get('fitMapBounds').enable({emitEvent: false});
this.mapSettingsFormGroup.get('useDefaultCenterPosition').enable({emitEvent: false});
const useDefaultCenterPosition: boolean = this.mapSettingsFormGroup.get('useDefaultCenterPosition').value;
if (useDefaultCenterPosition) {
this.mapSettingsFormGroup.get('defaultCenterPosition').enable({emitEvent: false});
} else {
this.mapSettingsFormGroup.get('defaultCenterPosition').disable({emitEvent: false});
}
this.mapSettingsFormGroup.get('defaultZoomLevel').enable({emitEvent: false});
} else {
this.mapSettingsFormGroup.get('layers').disable({emitEvent: false});
this.mapSettingsFormGroup.get('imageSourceType').enable({emitEvent: false});
if (imageSourceType === ImageSourceType.image) {
this.mapSettingsFormGroup.get('imageUrl').enable({emitEvent: false});
this.mapSettingsFormGroup.get('imageEntityAlias').disable({emitEvent: false});
this.mapSettingsFormGroup.get('imageUrlAttribute').disable({emitEvent: false});
} else {
this.mapSettingsFormGroup.get('imageUrl').disable({emitEvent: false});
this.mapSettingsFormGroup.get('imageEntityAlias').enable({emitEvent: false});
this.mapSettingsFormGroup.get('imageUrlAttribute').enable({emitEvent: false});
this.mapSettingsFormGroup.get('imageSource').enable({emitEvent: false});
this.mapSettingsFormGroup.get('fitMapBounds').disable({emitEvent: false});
this.mapSettingsFormGroup.get('useDefaultCenterPosition').disable({emitEvent: false});
this.mapSettingsFormGroup.get('defaultCenterPosition').disable({emitEvent: false});
this.mapSettingsFormGroup.get('defaultZoomLevel').disable({emitEvent: false});
}
}
private mapTypeChanged(mapType: MapType): void {
if (mapType === MapType.image) {
let imageSource: ImageMapSourceSettings = this.mapSettingsFormGroup.get('imageSource').value;
if (!imageSource?.sourceType) {
imageSource = mergeDeep({} as ImageMapSourceSettings, defaultImageMapSourceSettings);
this.mapSettingsFormGroup.get('imageSource').patchValue(imageSource);
}
}
}

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

@ -230,6 +230,9 @@ import { MapDataSourcesComponent } from '@home/components/widget/lib/settings/co
import {
MapDataSourceRowComponent
} from '@home/components/widget/lib/settings/common/map/map-data-source-row.component';
import {
ImageMapSourceSettingsComponent
} from '@home/components/widget/lib/settings/common/map/image-map-source-settings.component';
@NgModule({
declarations: [
@ -303,6 +306,7 @@ import {
MapLayerSettingsPanelComponent,
MapLayerRowComponent,
MapLayersComponent,
ImageMapSourceSettingsComponent,
DataLayerColorSettingsComponent,
DataLayerColorSettingsPanelComponent,
DataLayerPatternSettingsComponent,

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

@ -7548,6 +7548,33 @@
"map": "Map",
"image": "Image"
},
"image": {
"image-source": "Image source",
"image-source-image": "Image",
"image-source-entity-key": "Entity key",
"source-entity-alias": "Source entity alias",
"image-url-key": "Image URL key",
"image-url-key-required": "Image URL key is required"
},
"control": {
"map-controls": "Map controls",
"position": "Position",
"position-topleft": "Top-left",
"position-topright": "Top-right",
"position-bottomleft": "Bottom-left",
"position-bottomright": "Bottom-right",
"zoom-actions": "Zoom actions",
"zoom-scroll": "Scroll",
"zoom-double-click": "Double click",
"zoom-control-buttons": "Control buttons"
},
"common": {
"common-map-settings": "Common map settings",
"fit-map-bounds": "Fit map bounds to cover all markers",
"default-map-center-position": "Default map center position",
"default-map-zoom-level": "Default map zoom level",
"entities-limit": "Limit of entities to load"
},
"layer": {
"label": "Label",
"layer": "Layer",
@ -7667,6 +7694,8 @@
"marker-offset": "Marker offset",
"offset-horizontal": "Horizontal",
"offset-vertical": "Vertical",
"position-conversion": "Position conversion",
"position-conversion-function": "Position conversion function, should return x,y coordinates as double from 0 to 1 each",
"clustering": {
"use-map-markers-clustering": "Use map markers clustering",
"zoom-on-cluster-click": "Zoom when clicking on a cluster",

Loading…
Cancel
Save