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": [ "widgetTypeFqns": [
"map", "map",
"image_map",
"maps_v2.openstreetmap", "maps_v2.openstreetmap",
"maps_v2.google_maps", "maps_v2.google_maps",
"maps_v2.image_map", "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; 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) { protected onConfigSet(configData: WidgetConfigComponentData) {
const settings: MapWidgetSettings = mergeDeepIgnoreArray<MapWidgetSettings>({} as MapWidgetSettings, const settings: MapWidgetSettings = mergeDeepIgnoreArray<MapWidgetSettings>({} as MapWidgetSettings,
mapWidgetDefaultSettings, configData.config.settings 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); 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( return loadImageWithAspect(this.ctx.$injector.get(ImagePipe), url).pipe(
switchMap( aspectImage => { switchMap( aspectImage => {
if (aspectImage) { if (aspectImage) {
return of({ return of({
imageUrl: aspectImage.url, imageUrl: aspectImage.url,
aspect: aspectImage.aspect, aspect: aspectImage.aspect,
update: false update
}); });
} else { } 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> { private imageFromAlias(alias: Observable<[DataSet, boolean]>): Observable<MapImage> {
return alias.pipe( return alias.pipe(
switchMap(res => { switchMap(res => {
const update = res[1];
const url = res[0][0][1]; const url = res[0][0][1];
const mapImage: MapImage = { const mapImage: MapImage = {
imageUrl: null, imageUrl: null,
aspect: null, aspect: null,
update: res[1] update
}; };
return loadImageWithAspect(this.ctx.$injector.get(ImagePipe), url).pipe( return loadImageWithAspect(this.ctx.$injector.get(ImagePipe), url).pipe(
switchMap((aspectImage) => { switchMap((aspectImage) => {
@ -161,10 +162,10 @@ export class ImageMap extends LeafletMap {
mapImage.imageUrl = aspectImage.url; mapImage.imageUrl = aspectImage.url;
return of(mapImage); return of(mapImage);
} else { } 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> { class TbCircleDataLayerItem extends TbDataLayerItem<CirclesDataLayerSettings, TbCirclesDataLayer> {
private circle: L.Circle; private circle: L.Circle;
private circleStyle: L.PathOptions;
constructor(data: FormattedData<TbMapDatasource>, constructor(data: FormattedData<TbMapDatasource>,
dsData: 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 { protected create(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L.Layer {
const circleData = this.dataLayer.extractCircleCoordinates(data); const circleData = this.dataLayer.extractCircleCoordinates(data);
const center = new L.LatLng(circleData.latitude, circleData.longitude); 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, { this.circle = L.circle(center, {
radius: circleData.radius, radius: circleData.radius,
...style ...this.circleStyle
}); });
this.updateLabel(data, dsData); this.updateLabel(data, dsData);
return this.circle; return this.circle;
@ -63,7 +64,19 @@ class TbCircleDataLayerItem extends TbDataLayerItem<CirclesDataLayerSettings, Tb
.openTooltip(this.circle.getLatLng()); .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 circleData = this.dataLayer.extractCircleCoordinates(data);
const center = new L.LatLng(circleData.latitude, circleData.longitude); const center = new L.LatLng(circleData.latitude, circleData.longitude);
if (!this.circle.getLatLng().equals(center)) { if (!this.circle.getLatLng().equals(center)) {
@ -72,10 +85,6 @@ class TbCircleDataLayerItem extends TbDataLayerItem<CirclesDataLayerSettings, Tb
if (this.circle.getRadius() !== circleData.radius) { if (this.circle.getRadius() !== circleData.radius) {
this.circle.setRadius(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; return datasource;
} }
protected defaultBaseSettings(): Partial<CirclesDataLayerSettings> { protected defaultBaseSettings(map: TbMap<any>): Partial<CirclesDataLayerSettings> {
return defaultBaseCirclesDataLayerSettings; return defaultBaseCirclesDataLayerSettings(map.type());
} }
protected doSetup(): Observable<void> { 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 layer: L;
protected tooltip: L.Popup; protected tooltip: L.Popup;
protected data: FormattedData<TbMapDatasource>;
protected constructor(data: FormattedData<TbMapDatasource>, protected constructor(data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[], dsData: FormattedData<TbMapDatasource>[],
protected settings: S, protected settings: S,
protected dataLayer: D) { protected dataLayer: D) {
this.data = data;
this.layer = this.create(data, dsData); this.layer = this.create(data, dsData);
if (this.settings.tooltip?.show) { if (this.settings.tooltip?.show) {
this.createTooltip(data.$datasource); 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 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 unbindLabel(): void;
protected abstract bindLabel(content: L.Content): void; protected abstract bindLabel(content: L.Content): void;
protected abstract createEventListeners(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): 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() { public remove() {
this.layer.off(); this.layer.off();
@ -237,7 +250,7 @@ export abstract class TbMapDataLayer<S extends MapDataLayerSettings, D extends T
protected constructor(protected map: TbMap<any>, protected constructor(protected map: TbMap<any>,
inputSettings: S) { 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) { if (this.settings.groups?.length) {
this.settings.groups.forEach((group) => { this.settings.groups.forEach((group) => {
this.groupsState[group] = true; 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 { public getCtx(): WidgetContext {
return this.map.getCtx(); return this.map.getCtx();
} }
@ -349,7 +366,7 @@ export abstract class TbMapDataLayer<S extends MapDataLayerSettings, D extends T
public abstract dataLayerType(): MapDataLayerType; public abstract dataLayerType(): MapDataLayerType;
protected abstract defaultBaseSettings(): Partial<S>; protected abstract defaultBaseSettings(map: TbMap<any>): Partial<S>;
protected abstract doSetup(): Observable<any>; 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, BaseMarkerShapeSettings,
ClusterMarkerColorFunction, ClusterMarkerColorFunction,
DataLayerColorType, DataLayerColorType,
defaultBaseMarkersDataLayerSettings, isValidLatLng, defaultBaseMarkersDataLayerSettings,
isValidLatLng,
loadImageWithAspect, loadImageWithAspect,
MapStringFunction, MapType, MapStringFunction,
MapType,
MarkerIconInfo, MarkerIconInfo,
MarkerIconSettings, MarkerIconSettings,
MarkerImageFunction, MarkerImageFunction,
MarkerImageInfo, MarkerImageInfo,
MarkerImageSettings, MarkerImageSettings,
MarkerImageType, MarkerImageType,
MarkerPositionFunction,
MarkersDataLayerSettings, MarkersDataLayerSettings,
MarkerShapeSettings, MarkerShapeSettings,
MarkerType, MarkerType,
@ -48,7 +51,12 @@ import {
} from '@home/components/widget/lib/maps/models/marker-shape.models'; } from '@home/components/widget/lib/maps/models/marker-shape.models';
import { MatIconRegistry } from '@angular/material/icon'; import { MatIconRegistry } from '@angular/material/icon';
import { DomSanitizer } from '@angular/platform-browser'; 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> { 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 { 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, { this.marker = L.marker(this.location, {
tbMarkerData: data 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 }); this.marker.bindTooltip(content, { className: 'tb-marker-label', permanent: true, direction: 'top', offset: this.labelOffset });
} }
public update(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void { protected doUpdate(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void {
const position = this.dataLayer.extractLocation(data); 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)) { if (!this.marker.getLatLng().equals(position)) {
this.location = position; this.location = position;
this.marker.setLatLng(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>[]) { private updateMarkerIcon(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) {
@ -310,6 +326,7 @@ export class TbMarkersDataLayer extends TbMapDataLayer<MarkersDataLayerSettings,
private markersClusterContainer: L.MarkerClusterGroup; private markersClusterContainer: L.MarkerClusterGroup;
private clusterMarkerColorFunction: CompiledTbFunction<ClusterMarkerColorFunction>; private clusterMarkerColorFunction: CompiledTbFunction<ClusterMarkerColorFunction>;
private positionFunction: CompiledTbFunction<MarkerPositionFunction>;
constructor(protected map: TbMap<any>, constructor(protected map: TbMap<any>,
inputSettings: MarkersDataLayerSettings) { inputSettings: MarkersDataLayerSettings) {
@ -333,8 +350,8 @@ export class TbMarkersDataLayer extends TbMapDataLayer<MarkersDataLayerSettings,
return datasource; return datasource;
} }
protected defaultBaseSettings(): Partial<MarkersDataLayerSettings> { protected defaultBaseSettings(map: TbMap<any>): Partial<MarkersDataLayerSettings> {
return defaultBaseMarkersDataLayerSettings; return defaultBaseMarkersDataLayerSettings(map.type());
} }
protected doSetup(): Observable<void> { 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()); setup$.push(this.markerIconProcessor.setup());
return forkJoin(setup$).pipe(map(() => null)); return forkJoin(setup$).pipe(map(() => null));
} }
@ -496,9 +523,13 @@ export class TbMarkersDataLayer extends TbMapDataLayer<MarkersDataLayerSettings,
); );
} }
public extractLocation(data: FormattedData<TbMapDatasource>): L.LatLng { public extractLocation(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L.LatLng {
const position = this.extractPosition(data); let position = this.extractPosition(data);
if (position) { 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); return this.map.positionToLatLng(position);
} else { } else {
return null; 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 polygonContainer: L.FeatureGroup;
private polygon: L.Polygon; private polygon: L.Polygon;
private polygonStyle: L.PathOptions;
constructor(data: FormattedData<TbMapDatasource>, constructor(data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[], dsData: FormattedData<TbMapDatasource>[],
@ -43,9 +44,9 @@ class TbPolygonDataLayerItem extends TbDataLayerItem<PolygonsDataLayerSettings,
protected create(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L.Layer { protected create(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L.Layer {
const polyData = this.dataLayer.extractPolygonCoordinates(data); const polyData = this.dataLayer.extractPolygonCoordinates(data);
const polyConstructor = isCutPolygon(polyData) || polyData.length !== 2 ? L.polygon : L.rectangle; 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, { this.polygon = polyConstructor(polyData, {
...style ...this.polygonStyle
}); });
this.polygonContainer = L.featureGroup(); this.polygonContainer = L.featureGroup();
@ -68,14 +69,25 @@ class TbPolygonDataLayerItem extends TbDataLayerItem<PolygonsDataLayerSettings,
.openTooltip(this.polygonContainer.getBounds().getCenter()); .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 polyData = this.dataLayer.extractPolygonCoordinates(data);
const style = this.dataLayer.getShapeStyle(data, dsData);
if (isCutPolygon(polyData) || polyData.length !== 2) { if (isCutPolygon(polyData) || polyData.length !== 2) {
if (this.polygon instanceof L.Rectangle) { if (this.polygon instanceof L.Rectangle) {
this.polygonContainer.removeLayer(this.polygon); this.polygonContainer.removeLayer(this.polygon);
this.polygon = L.polygon(polyData, { this.polygon = L.polygon(polyData, {
...style ...this.polygonStyle
}); });
this.polygon.addTo(this.polygonContainer); this.polygon.addTo(this.polygonContainer);
} else { } else {
@ -83,13 +95,10 @@ class TbPolygonDataLayerItem extends TbDataLayerItem<PolygonsDataLayerSettings,
} }
} else if (polyData.length === 2) { } else if (polyData.length === 2) {
const bounds = new L.LatLngBounds(polyData); const bounds = new L.LatLngBounds(polyData);
// @ts-ignore (this.polygon as L.Rectangle).setBounds(bounds);
this.leafletPoly.setBounds(bounds);
} }
this.updateTooltip(data, dsData);
this.updateLabel(data, dsData);
this.polygon.setStyle(style);
} }
} }
export class TbPolygonsDataLayer extends TbShapesDataLayer<PolygonsDataLayerSettings, TbPolygonsDataLayer> { export class TbPolygonsDataLayer extends TbShapesDataLayer<PolygonsDataLayerSettings, TbPolygonsDataLayer> {
@ -108,8 +117,8 @@ export class TbPolygonsDataLayer extends TbShapesDataLayer<PolygonsDataLayerSett
return datasource; return datasource;
} }
protected defaultBaseSettings(): Partial<PolygonsDataLayerSettings> { protected defaultBaseSettings(map: TbMap<any>): Partial<PolygonsDataLayerSettings> {
return defaultBasePolygonsDataLayerSettings; return defaultBasePolygonsDataLayerSettings(map.type());
} }
protected doSetup(): Observable<any> { 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, ViewChild,
ViewEncapsulation ViewEncapsulation
} from '@angular/core'; } 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 { WidgetContext } from '@home/models/widget-component.models';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models'; 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'); const borderRadius = this.ctx.$widgetElement.css('borderRadius');
this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; this.overlayStyle = {...this.overlayStyle, ...{borderRadius}};
this.cd.detectChanges(); 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. /// 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 { BackgroundSettings, BackgroundType } from '@shared/models/widget-settings.models';
import { mergeDeep } from '@core/utils'; 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 { export interface MapWidgetSettings extends MapSetting {
background: BackgroundSettings; background: BackgroundSettings;
@ -36,3 +41,14 @@ export const mapWidgetDefaultSettings: MapWidgetSettings =
}, },
padding: '8px' padding: '8px'
} as MapWidgetSettings); } 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; border-bottom: none;
position: relative; position: relative;
background: transparent; background: transparent;
&:hover { &.leaflet-disabled {
border-bottom: none; color: rgba(0, 0, 0, 0.18);
color: $tb-primary-color; // primary color }
&:before { &:not(.leaflet-disabled) {
content: ""; &:hover {
position: absolute; border-bottom: none;
inset: 0; color: $tb-primary-color; // primary color
border-radius: 50%; &:before {
background-color: rgba(0, 105, 92, 0.10); content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background-color: rgba(0, 105, 92, 0.10);
}
} }
} }
&:first-child { &:first-child {

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

@ -17,16 +17,8 @@
import { import {
additionalMapDataSourcesToDatasources, additionalMapDataSourcesToDatasources,
BaseMapSettings, BaseMapSettings,
DEFAULT_ZOOM_LEVEL,
defaultGeoMapSettings,
defaultImageMapSettings,
GeoMapSettings,
ImageMapSettings,
latLngPointToBounds,
MapActionHandler, MapActionHandler,
MapSetting,
MapType, MapType,
MapZoomAction,
mergeMapDatasources, mergeMapDatasources,
parseCenterPosition, parseCenterPosition,
TbCircleData, TbCircleData,
@ -37,12 +29,11 @@ import { formattedDataFormDatasourceData, isDefinedAndNotNull, mergeDeepIgnoreAr
import { DeepPartial } from '@shared/models/common'; import { DeepPartial } from '@shared/models/common';
import L, { LatLngBounds, LatLngTuple, LeafletMouseEvent, Projection } from 'leaflet'; import L, { LatLngBounds, LatLngTuple, LeafletMouseEvent, Projection } from 'leaflet';
import { forkJoin, Observable, of } from 'rxjs'; import { forkJoin, Observable, of } from 'rxjs';
import { TbMapLayer } from '@home/components/widget/lib/maps/map-layer'; import { switchMap } from 'rxjs/operators';
import { map, switchMap, tap } from 'rxjs/operators';
import '@home/components/widget/lib/maps/leaflet/leaflet-tb'; import '@home/components/widget/lib/maps/leaflet/leaflet-tb';
import { MapDataLayerType, TbMapDataLayer, } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; import { MapDataLayerType, TbMapDataLayer, } from '@home/components/widget/lib/maps/data-layer/map-data-layer';
import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; 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 { EntityDataPageLink } from '@shared/models/query/query.models';
import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe';
import { TbMarkersDataLayer } from '@home/components/widget/lib/maps/data-layer/markers-data-layer'; 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> { 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 settings: S;
protected map: L.Map; 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 northEast = new L.LatLng(Projection.SphericalMercator['MAX_LATITUDE'], 180);
protected dataLayers: TbMapDataLayer<any,any>[]; protected dataLayers: TbMapDataLayer<any,any>[];
protected dsData: FormattedData<TbMapDatasource>[];
protected mapElement: HTMLElement; protected mapElement: HTMLElement;
@ -122,7 +103,9 @@ export abstract class TbMap<S extends BaseMapSettings> {
} }
private setupControls(): Observable<any> { 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(); return this.doSetupControls();
} }
@ -288,9 +271,9 @@ export abstract class TbMap<S extends BaseMapSettings> {
} }
private update(subscription: IWidgetSubscription) { 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]); 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(); this.updateBounds();
} }
@ -306,7 +289,6 @@ export abstract class TbMap<S extends BaseMapSettings> {
if (dataLayersBounds.length) { if (dataLayersBounds.length) {
bounds = new L.LatLngBounds(null, null); bounds = new L.LatLngBounds(null, null);
dataLayersBounds.forEach(b => bounds.extend(b)); dataLayersBounds.forEach(b => bounds.extend(b));
const mapBounds = this.map.getBounds(); const mapBounds = this.map.getBounds();
if (bounds.isValid() && this.settings.fitMapBounds && !mapBounds.contains(bounds)) { if (bounds.isValid() && this.settings.fitMapBounds && !mapBounds.contains(bounds)) {
if (!this.ignoreUpdateBounds) { 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 } { private loadActions(name: string): { [name: string]: MapActionHandler } {
const descriptors = this.ctx.actionsApi.getActionDescriptors(name); const descriptors = this.ctx.actionsApi.getActionDescriptors(name);
const actions: { [name: string]: MapActionHandler } = {}; const actions: { [name: string]: MapActionHandler } = {};
@ -373,10 +326,16 @@ export abstract class TbMap<S extends BaseMapSettings> {
protected abstract onResize(): void; protected abstract onResize(): void;
protected abstract fitBounds(bounds: LatLngBounds): void;
protected doSetupControls(): Observable<any> { protected doSetupControls(): Observable<any> {
return of(null); return of(null);
} }
protected invalidateDataLayersCoordinates(): void {
this.dataLayers.forEach(dl => dl.invalidateCoordinates());
}
protected getSidebar(): L.TB.SidebarControl { protected getSidebar(): L.TB.SidebarControl {
if (!this.sidebar) { if (!this.sidebar) {
this.sidebar = L.TB.sidebar({ this.sidebar = L.TB.sidebar({
@ -392,6 +351,10 @@ export abstract class TbMap<S extends BaseMapSettings> {
return this.ctx; return this.ctx;
} }
public getData(): FormattedData<TbMapDatasource>[] {
return this.dsData;
}
public getMap(): L.Map { public getMap(): L.Map {
return this.map; return this.map;
} }
@ -459,154 +422,3 @@ export abstract class TbMap<S extends BaseMapSettings> {
public abstract convertCircleData(circle: TbCircleData): TbCircleData; 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 { DataKey, Datasource, DatasourceType, FormattedData } from '@shared/models/widget.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.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 { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
import { materialColors } from '@shared/models/material.models'; import { materialColors } from '@shared/models/material.models';
import L from 'leaflet'; import L from 'leaflet';
@ -96,7 +96,7 @@ export interface MapDataLayerSettings extends MapDataSourceSettings {
groups?: string[]; groups?: string[];
} }
export const defaultBaseDataLayerSettings: Partial<MapDataLayerSettings> = { export const defaultBaseDataLayerSettings = (mapType: MapType): Partial<MapDataLayerSettings> => ({
label: { label: {
show: true, show: true,
type: DataLayerPatternType.pattern, type: DataLayerPatternType.pattern,
@ -107,11 +107,13 @@ export const defaultBaseDataLayerSettings: Partial<MapDataLayerSettings> = {
trigger: DataLayerTooltipTrigger.click, trigger: DataLayerTooltipTrigger.click,
autoclose: true, autoclose: true,
type: DataLayerPatternType.pattern, 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, offsetX: 0,
offsetY: -1 offsetY: -1
} }
} })
export type MapDataLayerType = 'markers' | 'polygons' | 'circles'; export type MapDataLayerType = 'markers' | 'polygons' | 'circles';
@ -235,6 +237,7 @@ export interface MarkersDataLayerSettings extends MapDataLayerSettings {
markerImage?: MarkerImageSettings; markerImage?: MarkerImageSettings;
markerOffsetX: number; markerOffsetX: number;
markerOffsetY: number; markerOffsetY: number;
positionFunction?: TbFunction;
markerClustering: MarkerClusteringSettings; markerClustering: MarkerClusteringSettings;
} }
@ -281,9 +284,9 @@ export const defaultMarkersDataLayerSettings = (mapType: MapType, functionsOnly
settings: {}, settings: {},
color: materialColors[0].value 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, markerType: MarkerType.shape,
markerShape: { markerShape: {
shape: MarkerShape.markerShape1, shape: MarkerShape.markerShape1,
@ -308,6 +311,7 @@ export const defaultBaseMarkersDataLayerSettings: Partial<MarkersDataLayerSettin
}, },
markerOffsetX: 0.5, markerOffsetX: 0.5,
markerOffsetY: 1, markerOffsetY: 1,
positionFunction: 'return {x: origXPos, y: origYPos};',
markerClustering: { markerClustering: {
enable: false, enable: false,
zoomOnClick: true, zoomOnClick: true,
@ -321,7 +325,7 @@ export const defaultBaseMarkersDataLayerSettings: Partial<MarkersDataLayerSettin
useClusterMarkerColorFunction: false, useClusterMarkerColorFunction: false,
clusterMarkerColorFunction: null clusterMarkerColorFunction: null
} }
} as MarkersDataLayerSettings, defaultBaseDataLayerSettings); } as MarkersDataLayerSettings, defaultBaseDataLayerSettings(mapType));
export interface ShapeDataLayerSettings extends MapDataLayerSettings { export interface ShapeDataLayerSettings extends MapDataLayerSettings {
fillColor: DataLayerColorSettings; fillColor: DataLayerColorSettings;
@ -333,7 +337,7 @@ export interface PolygonsDataLayerSettings extends ShapeDataLayerSettings {
polygonKey: DataKey; polygonKey: DataKey;
} }
export const defaultPolygonsDataLayerSettings = (functionsOnly = false): PolygonsDataLayerSettings => mergeDeep({ export const defaultPolygonsDataLayerSettings = (mapType: MapType, functionsOnly = false): PolygonsDataLayerSettings => mergeDeep({
dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity,
dsLabel: functionsOnly ? 'First polygon' : '', dsLabel: functionsOnly ? 'First polygon' : '',
polygonKey: { polygonKey: {
@ -343,9 +347,9 @@ export const defaultPolygonsDataLayerSettings = (functionsOnly = false): Polygon
settings: {}, settings: {},
color: materialColors[0].value 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: { fillColor: {
type: DataLayerColorType.constant, type: DataLayerColorType.constant,
color: 'rgba(51,136,255,0.2)', color: 'rgba(51,136,255,0.2)',
@ -355,14 +359,14 @@ export const defaultBasePolygonsDataLayerSettings: Partial<PolygonsDataLayerSett
color: '#3388ff', color: '#3388ff',
}, },
strokeWeight: 3 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>) {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 { export interface CirclesDataLayerSettings extends ShapeDataLayerSettings {
circleKey: DataKey; circleKey: DataKey;
} }
export const defaultCirclesDataLayerSettings = (functionsOnly = false): CirclesDataLayerSettings => mergeDeep({ export const defaultCirclesDataLayerSettings = (mapType: MapType, functionsOnly = false): CirclesDataLayerSettings => mergeDeep({
dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity,
dsLabel: functionsOnly ? 'First circle' : '', dsLabel: functionsOnly ? 'First circle' : '',
circleKey: { circleKey: {
@ -372,9 +376,9 @@ export const defaultCirclesDataLayerSettings = (functionsOnly = false): CirclesD
settings: {}, settings: {},
color: materialColors[0].value 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: { fillColor: {
type: DataLayerColorType.constant, type: DataLayerColorType.constant,
color: 'rgba(51,136,255,0.2)', color: 'rgba(51,136,255,0.2)',
@ -384,7 +388,7 @@ export const defaultBaseCirclesDataLayerSettings: Partial<CirclesDataLayerSettin
color: '#3388ff', color: '#3388ff',
}, },
strokeWeight: 3 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>) {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 => { export const defaultMapDataLayerSettings = (mapType: MapType, dataLayerType: MapDataLayerType, functionsOnly = false): MapDataLayerSettings => {
@ -392,20 +396,20 @@ export const defaultMapDataLayerSettings = (mapType: MapType, dataLayerType: Map
case 'markers': case 'markers':
return defaultMarkersDataLayerSettings(mapType, functionsOnly); return defaultMarkersDataLayerSettings(mapType, functionsOnly);
case 'polygons': case 'polygons':
return defaultPolygonsDataLayerSettings(functionsOnly); return defaultPolygonsDataLayerSettings(mapType, functionsOnly);
case 'circles': 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) { switch (dataLayerType) {
case 'markers': case 'markers':
return defaultBaseMarkersDataLayerSettings as T; return defaultBaseMarkersDataLayerSettings(mapType) as T;
case 'polygons': case 'polygons':
return defaultBasePolygonsDataLayerSettings as T; return defaultBasePolygonsDataLayerSettings(mapType) as T;
case 'circles': case 'circles':
return defaultBaseCirclesDataLayerSettings as T; return defaultBaseCirclesDataLayerSettings(mapType) as T;
} }
} }
@ -453,12 +457,33 @@ export enum MapControlsPosition {
bottomright = 'bottomright' 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 { export enum MapZoomAction {
scroll = 'scroll', scroll = 'scroll',
doubleClick = 'doubleClick', doubleClick = 'doubleClick',
controlButtons = 'controlButtons' 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 { export interface BaseMapSettings {
mapType: MapType; mapType: MapType;
markers: MarkersDataLayerSettings[]; markers: MarkersDataLayerSettings[];
@ -756,20 +781,57 @@ export const defaultGeoMapSettings: GeoMapSettings = {
export enum ImageSourceType { export enum ImageSourceType {
image = 'image', 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 { export interface ImageMapSettings extends BaseMapSettings {
imageSourceType?: ImageSourceType; imageSource?: ImageMapSourceSettings;
imageUrl?: string;
imageEntityAlias?: string;
imageUrlAttribute?: string;
} }
export const defaultImageMapSettings: ImageMapSettings = { export const defaultImageMapSettings: ImageMapSettings = {
mapType: MapType.image, mapType: MapType.image,
imageSourceType: ImageSourceType.image, imageSource: mergeDeep({} as ImageMapSourceSettings, defaultImageMapSourceSettings),
imageUrl: 'data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==',
...mergeDeep({} as BaseMapSettings, defaultBaseMapSettings) ...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 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 { export interface TbCircleData {
latitude: number; latitude: number;
longitude: 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. 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, [class]="{'tb-inline-field': inlineField,
'tb-suffix-absolute': (inlineField && !keysFormControl.value?.length)}" 'tb-suffix-absolute': (inlineField && !keysFormControl.value?.length)}"
[appearance]="inlineField ? 'fill' : appearance" [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; deviceId: string;
@Input() @Input()
generateKey: (key: DataKey) => DataKey = (key) => key; generateKey: (key: DataKey) => DataKey;
private requiredValue: boolean; private requiredValue: boolean;
get required(): 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> </mat-form-field>
</div> </div>
</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>
<ng-container *ngIf="['polygons', 'circles'].includes(dataLayerType)"> <ng-container *ngIf="['polygons', 'circles'].includes(dataLayerType)">
<div class="tb-form-row space-between"> <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 { Component, DestroyRef, Inject, ViewEncapsulation } from '@angular/core';
import { DialogComponent } from '@shared/components/dialog.component'; import { DialogComponent } from '@shared/components/dialog.component';
import { import {
CirclesDataLayerSettings, defaultBaseMapDataLayerSettings, CirclesDataLayerSettings,
defaultBaseMapDataLayerSettings,
MapDataLayerSettings, MapDataLayerSettings,
MapDataLayerType, MapDataLayerType,
MapType, MapType,
MarkersDataLayerSettings, MarkersDataLayerSettings,
MarkerType, MarkerType,
PolygonsDataLayerSettings, ShapeDataLayerSettings PolygonsDataLayerSettings,
ShapeDataLayerSettings
} from '@home/components/widget/lib/maps/models/map.models'; } from '@home/components/widget/lib/maps/models/map.models';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; 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 { EntityType } from '@shared/models/entity-type.models';
import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models';
import { genNextLabelForDataKeys, mergeDeepIgnoreArray } from '@core/utils'; 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 { export interface MapDataLayerDialogData {
settings: MapDataLayerSettings; settings: MapDataLayerSettings;
@ -76,6 +80,8 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
generateAdditionalDataKey = this.generateDataKey.bind(this); generateAdditionalDataKey = this.generateDataKey.bind(this);
functionScopeVariables = this.widgetService.getWidgetScopeVariables();
dialogTitle: string; dialogTitle: string;
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
@ -83,6 +89,7 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
@Inject(MAT_DIALOG_DATA) public data: MapDataLayerDialogData, @Inject(MAT_DIALOG_DATA) public data: MapDataLayerDialogData,
public dialogRef: MatDialogRef<MapDataLayerDialogComponent, MapDataLayerSettings>, public dialogRef: MatDialogRef<MapDataLayerDialogComponent, MapDataLayerSettings>,
private fb: FormBuilder, private fb: FormBuilder,
private widgetService: WidgetService,
private destroyRef: DestroyRef) { private destroyRef: DestroyRef) {
super(store, router, dialogRef); super(store, router, dialogRef);
@ -93,7 +100,7 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
} }
this.settings = mergeDeepIgnoreArray({} as MapDataLayerSettings, this.settings = mergeDeepIgnoreArray({} as MapDataLayerSettings,
defaultBaseMapDataLayerSettings<MapDataLayerSettings>(this.dataLayerType), this.settings); defaultBaseMapDataLayerSettings<MapDataLayerSettings>(this.mapType, this.dataLayerType), this.settings);
this.dataLayerFormGroup = this.fb.group({ this.dataLayerFormGroup = this.fb.group({
dsType: [this.settings.dsType, [Validators.required]], 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('markerImage', this.fb.control(markersDataLayer.markerImage, Validators.required));
this.dataLayerFormGroup.addControl('markerOffsetX', this.fb.control(markersDataLayer.markerOffsetX)); this.dataLayerFormGroup.addControl('markerOffsetX', this.fb.control(markersDataLayer.markerOffsetX));
this.dataLayerFormGroup.addControl('markerOffsetY', this.fb.control(markersDataLayer.markerOffsetY)); 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.addControl('markerClustering', this.fb.control(markersDataLayer.markerClustering));
this.dataLayerFormGroup.get('markerType').valueChanges.pipe( this.dataLayerFormGroup.get('markerType').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
@ -292,4 +302,6 @@ export class MapDataLayerDialogComponent extends DialogComponent<MapDataLayerDia
const settings: MapDataLayerSettings = this.dataLayerFormGroup.getRawValue(); const settings: MapDataLayerSettings = this.dataLayerFormGroup.getRawValue();
this.dialogRef.close(settings); 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> </tb-toggle-select>
</div> </div>
<tb-map-layers *ngIf="MapType.geoMap === mapSettingsFormGroup.get('mapType').value" <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>
<div class="tb-form-panel"> <div class="tb-form-panel">
<div class="flex flex-row items-center justify-between"> <div class="flex flex-row items-center justify-between">
@ -65,4 +70,59 @@
[context]="context"> [context]="context">
</tb-map-data-sources> </tb-map-data-sources>
</div> </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> </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, UntypedFormBuilder,
UntypedFormControl, UntypedFormControl,
UntypedFormGroup, UntypedFormGroup,
Validator Validator,
Validators
} from '@angular/forms'; } 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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { merge, Observable } from 'rxjs'; import { merge, Observable } from 'rxjs';
import { coerceBoolean } from '@shared/decorators/coercion'; import { coerceBoolean } from '@shared/decorators/coercion';
@ -36,7 +47,7 @@ import {
DataKeyConfigDialogComponent, DataKeyConfigDialogComponent,
DataKeyConfigDialogData DataKeyConfigDialogData
} from '@home/components/widget/lib/settings/common/key/data-key-config-dialog.component'; } 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'; import { MatDialog } from '@angular/material/dialog';
@Component({ @Component({
@ -58,6 +69,14 @@ import { MatDialog } from '@angular/material/dialog';
}) })
export class MapSettingsComponent implements OnInit, ControlValueAccessor, Validator { export class MapSettingsComponent implements OnInit, ControlValueAccessor, Validator {
mapControlPositions = mapControlPositions;
mapZoomActions = mapZoomActions;
mapControlsPositionTranslationMap = mapControlsPositionTranslationMap;
mapZoomActionTranslationMap = mapZoomActionTranslationMap;
MapType = MapType; MapType = MapType;
@Input() @Input()
@ -105,10 +124,7 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid
this.mapSettingsFormGroup = this.fb.group({ this.mapSettingsFormGroup = this.fb.group({
mapType: [null, []], mapType: [null, []],
layers: [null, []], layers: [null, []],
imageSourceType: [null, []], imageSource: [null, [imageMapSourceSettingsValidator]],
imageUrl: [null, []],
imageEntityAlias: [null, []],
imageUrlAttribute: [null, []],
markers: [null, []], markers: [null, []],
polygons: [null, []], polygons: [null, []],
circles: [null, []], circles: [null, []],
@ -118,8 +134,8 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid
fitMapBounds: [null, []], fitMapBounds: [null, []],
useDefaultCenterPosition: [null, []], useDefaultCenterPosition: [null, []],
defaultCenterPosition: [null, []], defaultCenterPosition: [null, []],
defaultZoomLevel: [null, []], defaultZoomLevel: [null, [Validators.min(0), Validators.max(20)]],
mapPageSize: [null, []] mapPageSize: [null, [Validators.min(1), Validators.required]]
}); });
this.mapSettingsFormGroup.valueChanges.pipe( this.mapSettingsFormGroup.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
@ -127,12 +143,17 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid
this.updateModel(); this.updateModel();
}); });
merge(this.mapSettingsFormGroup.get('mapType').valueChanges, merge(this.mapSettingsFormGroup.get('mapType').valueChanges,
this.mapSettingsFormGroup.get('imageSourceType').valueChanges this.mapSettingsFormGroup.get('useDefaultCenterPosition').valueChanges
).pipe( ).pipe(
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
).subscribe(() => { ).subscribe(() => {
this.updateValidators(); this.updateValidators();
}); });
this.mapSettingsFormGroup.get('mapType').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe((mapType: MapType) => {
this.mapTypeChanged(mapType);
});
} }
registerOnChange(fn: any): void { registerOnChange(fn: any): void {
@ -160,7 +181,7 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid
this.updateValidators(); this.updateValidators();
} }
public validate(c: UntypedFormControl) { public validate(_c: UntypedFormControl) {
const valid = this.mapSettingsFormGroup.valid; const valid = this.mapSettingsFormGroup.valid;
return valid ? null : { return valid ? null : {
mapSettings: { mapSettings: {
@ -171,24 +192,34 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid
private updateValidators() { private updateValidators() {
const mapType: MapType = this.mapSettingsFormGroup.get('mapType').value; const mapType: MapType = this.mapSettingsFormGroup.get('mapType').value;
const imageSourceType: ImageSourceType = this.mapSettingsFormGroup.get('imageSourceType').value;
if (mapType === MapType.geoMap) { if (mapType === MapType.geoMap) {
this.mapSettingsFormGroup.get('layers').enable({emitEvent: false}); this.mapSettingsFormGroup.get('layers').enable({emitEvent: false});
this.mapSettingsFormGroup.get('imageSourceType').disable({emitEvent: false}); this.mapSettingsFormGroup.get('imageSource').disable({emitEvent: false});
this.mapSettingsFormGroup.get('imageUrl').disable({emitEvent: false}); this.mapSettingsFormGroup.get('fitMapBounds').enable({emitEvent: false});
this.mapSettingsFormGroup.get('imageEntityAlias').disable({emitEvent: false}); this.mapSettingsFormGroup.get('useDefaultCenterPosition').enable({emitEvent: false});
this.mapSettingsFormGroup.get('imageUrlAttribute').disable({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 { } else {
this.mapSettingsFormGroup.get('layers').disable({emitEvent: false}); this.mapSettingsFormGroup.get('layers').disable({emitEvent: false});
this.mapSettingsFormGroup.get('imageSourceType').enable({emitEvent: false}); this.mapSettingsFormGroup.get('imageSource').enable({emitEvent: false});
if (imageSourceType === ImageSourceType.image) { this.mapSettingsFormGroup.get('fitMapBounds').disable({emitEvent: false});
this.mapSettingsFormGroup.get('imageUrl').enable({emitEvent: false}); this.mapSettingsFormGroup.get('useDefaultCenterPosition').disable({emitEvent: false});
this.mapSettingsFormGroup.get('imageEntityAlias').disable({emitEvent: false}); this.mapSettingsFormGroup.get('defaultCenterPosition').disable({emitEvent: false});
this.mapSettingsFormGroup.get('imageUrlAttribute').disable({emitEvent: false}); this.mapSettingsFormGroup.get('defaultZoomLevel').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}); 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 { import {
MapDataSourceRowComponent MapDataSourceRowComponent
} from '@home/components/widget/lib/settings/common/map/map-data-source-row.component'; } 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({ @NgModule({
declarations: [ declarations: [
@ -303,6 +306,7 @@ import {
MapLayerSettingsPanelComponent, MapLayerSettingsPanelComponent,
MapLayerRowComponent, MapLayerRowComponent,
MapLayersComponent, MapLayersComponent,
ImageMapSourceSettingsComponent,
DataLayerColorSettingsComponent, DataLayerColorSettingsComponent,
DataLayerColorSettingsPanelComponent, DataLayerColorSettingsPanelComponent,
DataLayerPatternSettingsComponent, DataLayerPatternSettingsComponent,

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

@ -7548,6 +7548,33 @@
"map": "Map", "map": "Map",
"image": "Image" "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": { "layer": {
"label": "Label", "label": "Label",
"layer": "Layer", "layer": "Layer",
@ -7667,6 +7694,8 @@
"marker-offset": "Marker offset", "marker-offset": "Marker offset",
"offset-horizontal": "Horizontal", "offset-horizontal": "Horizontal",
"offset-vertical": "Vertical", "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": { "clustering": {
"use-map-markers-clustering": "Use map markers clustering", "use-map-markers-clustering": "Use map markers clustering",
"zoom-on-cluster-click": "Zoom when clicking on a cluster", "zoom-on-cluster-click": "Zoom when clicking on a cluster",

Loading…
Cancel
Save