Browse Source

UI: Maps - Trips data layer.

pull/12807/head
Igor Kulikov 2 years ago
parent
commit
56fdaf173f
  1. 1
      application/src/main/data/json/system/widget_bundles/maps.json
  2. 47
      application/src/main/data/json/system/widget_types/trip_map.json
  3. 12
      ui-ngx/src/app/core/utils.ts
  4. 3
      ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html
  5. 28
      ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.ts
  6. 3
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts
  7. 416
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts
  8. 9
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss
  9. 192
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts
  10. 102
      ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts
  11. 26
      ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html
  12. 19
      ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss
  13. 68
      ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts
  14. 4
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts
  15. 2
      ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
  16. 6
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.html
  17. 4
      ui-ngx/src/assets/locale/locale.constant-en_US.json

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

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

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

File diff suppressed because one or more lines are too long

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

@ -512,18 +512,20 @@ export function formattedDataFormDatasourceData<D extends Datasource = Datasourc
});
}
export function formattedDataArrayFromDatasourceData(input: DatasourceData[]): FormattedData[][] {
return _(input).groupBy(el => el.datasource.entityName)
export function formattedDataArrayFromDatasourceData<D extends Datasource = Datasource>(input: DatasourceData[],
groupFunction: (el: DatasourceData) => any =
(el) => el.datasource.entityName + el.datasource.entityType): FormattedData<D>[][] {
return _(input).groupBy(groupFunction)
.values().value().map((entityArray, dsIndex) => {
const timeDataMap: {[time: number]: FormattedData} = {};
const timeDataMap: {[time: number]: FormattedData<D>} = {};
entityArray.filter(e => e.data.length).forEach(entity => {
entity.data.forEach(tsData => {
const time = tsData[0];
const value = tsData[1];
let data = timeDataMap[time];
if (!data) {
const datasource = entity.datasource;
data = formattedDataFromDatasource(datasource, dsIndex);
const datasource = entity.datasource as D;
data = formattedDataFromDatasource<D>(datasource, dsIndex);
data.time = time;
timeDataMap[time] = data;
}

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

@ -16,7 +16,10 @@
-->
<ng-container [formGroup]="mapWidgetConfigForm">
<tb-timewindow-config-panel *ngIf="trip" formControlName="timewindowConfig">
</tb-timewindow-config-panel>
<tb-map-settings
[trip]="trip"
[functionsOnly]="functionsOnly"
[aliasController]="aliasController"
[callbacks]="callbacks"

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

@ -21,10 +21,14 @@ import { AppState } from '@core/core.state';
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { WidgetConfigComponentData } from '@home/models/widget-component.models';
import { isUndefined, mergeDeep, mergeDeepIgnoreArray } from '@core/utils';
import { isDefinedAndNotNull, isUndefined, mergeDeep, mergeDeepIgnoreArray } from '@core/utils';
import { mapWidgetDefaultSettings, MapWidgetSettings } from '@home/components/widget/lib/maps/map-widget.models';
import { cssSizeToStrSize, resolveCssSize } from '@shared/models/widget-settings.models';
import { WidgetConfig } from '@shared/models/widget.models';
import {
getTimewindowConfig,
setTimewindowConfig
} from '@home/components/widget/config/timewindow-config-panel.component';
@Component({
selector: 'tb-map-basic-config',
@ -35,6 +39,8 @@ export class MapBasicConfigComponent extends BasicWidgetConfigComponent {
mapWidgetConfigForm: UntypedFormGroup;
trip = false;
constructor(protected store: Store<AppState>,
protected widgetConfigComponent: WidgetConfigComponent,
private $injector: Injector,
@ -46,6 +52,14 @@ export class MapBasicConfigComponent extends BasicWidgetConfigComponent {
return this.mapWidgetConfigForm;
}
protected setupConfig(widgetConfig: WidgetConfigComponentData) {
const params = widgetConfig.typeParameters as any;
if (isDefinedAndNotNull(params.trip)) {
this.trip = params.trip === true;
}
super.setupConfig(widgetConfig);
}
protected setupDefaults(configData: WidgetConfigComponentData) {
const settings = configData.config.settings as MapWidgetSettings;
if (settings?.markers?.length) {
@ -57,6 +71,11 @@ export class MapBasicConfigComponent extends BasicWidgetConfigComponent {
if (settings?.circles?.length) {
settings.circles = [];
}
if (this.trip) {
if (settings?.trips?.length) {
settings.trips = [];
}
}
}
protected onConfigSet(configData: WidgetConfigComponentData) {
@ -85,10 +104,15 @@ export class MapBasicConfigComponent extends BasicWidgetConfigComponent {
actions: [configData.config.actions || {}, []]
});
if (this.trip) {
this.mapWidgetConfigForm.addControl('timewindowConfig', this.fb.control(getTimewindowConfig(configData.config)))
}
}
protected prepareOutputConfig(config: any): WidgetConfigComponentData {
if (this.trip) {
setTimewindowConfig(this.widgetConfig.config, config.timewindowConfig);
}
this.widgetConfig.config.settings = config.mapSettings || {};
this.widgetConfig.config.showTitle = config.showTitle;

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

@ -306,12 +306,13 @@ export abstract class TbDataLayerItem<S extends MapDataLayerSettings = MapDataLa
}
export enum MapDataLayerType {
trip = 'trip',
marker = 'marker',
polygon = 'polygon',
circle = 'circle'
}
class DataLayerPatternProcessor {
export class DataLayerPatternProcessor {
private patternFunction: CompiledTbFunction<MapStringFunction>;
private pattern: string;

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

@ -0,0 +1,416 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT 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 {
DataLayerColorProcessor,
DataLayerPatternProcessor,
MapDataLayerType, TbDataLayerItem,
TbMapDataLayer,
UnplacedMapDataItem
} from '@home/components/widget/lib/maps/data-layer/map-data-layer';
import {
BaseMarkerShapeSettings, ClusterMarkerColorFunction,
DataLayerColorType, defaultBaseMarkersDataLayerSettings, defaultBaseTripsDataLayerSettings,
loadImageWithAspect,
MapStringFunction, MapType,
MarkerIconInfo,
MarkerIconSettings,
MarkerImageFunction,
MarkerImageInfo,
MarkerImageSettings,
MarkerImageType,
MarkerPositionFunction, MarkersDataLayerSettings,
MarkerShapeSettings,
MarkerType,
TbMapDatasource,
TripsDataLayerSettings
} from '@home/components/widget/lib/maps/models/map.models';
import { forkJoin, Observable, of } from 'rxjs';
import { FormattedData } from '@shared/models/widget.models';
import {
createColorMarkerIconElement,
createColorMarkerShapeURI,
MarkerShape
} from '@home/components/widget/lib/maps/models/marker-shape.models';
import tinycolor from 'tinycolor2';
import { MatIconRegistry } from '@angular/material/icon';
import { DomSanitizer } from '@angular/platform-browser';
import { catchError, map, switchMap } from 'rxjs/operators';
import L from 'leaflet';
import { CompiledTbFunction } from '@shared/models/js-function.models';
import { isDefined, isDefinedAndNotNull, parseTbFunction, safeExecuteTbFunction } from '@core/utils';
import { ImagePipe } from '@shared/pipe/image.pipe';
import { TbMap } from '@home/components/widget/lib/maps/map';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
abstract class TripMarkerIconProcessor<S> {
static fromSettings(dataLayer: TbTripsDataLayer,
settings: TripsDataLayerSettings): TripMarkerIconProcessor<any> {
switch (settings.markerType) {
case MarkerType.shape:
return new ShapeTripMarkerIconProcessor(dataLayer, settings.markerShape);
case MarkerType.icon:
return new IconTripMarkerIconProcessor(dataLayer, settings.markerIcon);
case MarkerType.image:
return new ImageTripMarkerIconProcessor(dataLayer, settings.markerImage);
}
}
protected constructor(protected dataLayer: TbTripsDataLayer,
protected settings: S) {}
public abstract setup(): Observable<void>;
public abstract createMarkerIcon(data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[]): Observable<MarkerIconInfo>;
}
abstract class BaseColorTripMarkerShapeProcessor<S extends BaseMarkerShapeSettings> extends TripMarkerIconProcessor<S> {
private markerColorFunction: CompiledTbFunction<MapStringFunction>;
protected constructor(protected dataLayer: TbTripsDataLayer,
protected settings: S) {
super(dataLayer, settings);
}
public setup(): Observable<void> {
const colorSettings = this.settings.color;
if (colorSettings.type === DataLayerColorType.function) {
return parseTbFunction<MapStringFunction>(this.dataLayer.getCtx().http, colorSettings.colorFunction, ['data', 'dsData']).pipe(
map((parsed) => {
this.markerColorFunction = parsed;
return null;
})
);
} else {
return of(null);
}
}
public createMarkerIcon(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): Observable<MarkerIconInfo> {
const colorSettings = this.settings.color;
let color: tinycolor.Instance;
if (colorSettings.type === DataLayerColorType.function) {
const functionColor = safeExecuteTbFunction(this.markerColorFunction, [data, dsData]);
if (isDefinedAndNotNull(functionColor)) {
color = tinycolor(functionColor);
} else {
color = tinycolor(colorSettings.color);
}
} else {
color = tinycolor(colorSettings.color);
}
return this.createMarkerShape(color, data.rotationAngle, this.settings.size);
}
protected abstract createMarkerShape(color: tinycolor.Instance, rotationAngle: number, size: number): Observable<MarkerIconInfo>;
}
class ShapeTripMarkerIconProcessor extends BaseColorTripMarkerShapeProcessor<MarkerShapeSettings> {
constructor(protected dataLayer: TbTripsDataLayer,
protected settings: MarkerShapeSettings) {
super(dataLayer, settings);
}
protected createMarkerShape(color: tinycolor.Instance, rotationAngle: number, size: number): Observable<MarkerIconInfo> {
return this.dataLayer.createColoredMarkerShape(this.settings.shape, color, rotationAngle, size);
}
}
class IconTripMarkerIconProcessor extends BaseColorTripMarkerShapeProcessor<MarkerIconSettings> {
constructor(protected dataLayer: TbTripsDataLayer,
protected settings: MarkerIconSettings) {
super(dataLayer, settings);
}
protected createMarkerShape(color: tinycolor.Instance, rotationAngle: number, size: number): Observable<MarkerIconInfo> {
return this.dataLayer.createColoredMarkerIcon(this.settings.icon, color, rotationAngle, size);
}
}
class ImageTripMarkerIconProcessor extends TripMarkerIconProcessor<MarkerImageSettings> {
private markerImageFunction: CompiledTbFunction<MarkerImageFunction>;
constructor(protected dataLayer: TbTripsDataLayer,
protected settings: MarkerImageSettings) {
super(dataLayer, settings);
}
public setup(): Observable<void> {
if (this.settings.type === MarkerImageType.function) {
return parseTbFunction<MarkerImageFunction>(this.dataLayer.getCtx().http, this.settings.imageFunction, ['data', 'images', 'dsData']).pipe(
map((parsed) => {
this.markerImageFunction = parsed;
return null;
})
);
} else {
return of(null);
}
}
public createMarkerIcon(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): Observable<MarkerIconInfo> {
if (this.settings.type === MarkerImageType.function) {
const currentImage: MarkerImageInfo = safeExecuteTbFunction(this.markerImageFunction, [data, this.settings.images, dsData]);
return this.loadMarkerIconInfo(currentImage, data.rotationAngle);
} else {
const currentImage: MarkerImageInfo = {
url: this.settings.image,
size: this.settings.imageSize || 34
};
return this.loadMarkerIconInfo(currentImage, data.rotationAngle);
}
}
private loadMarkerIconInfo(image: MarkerImageInfo, rotationAngle = 0): Observable<MarkerIconInfo> {
if (image && image.url) {
return loadImageWithAspect(this.dataLayer.getCtx().$injector.get(ImagePipe), image.url).pipe(
switchMap((aspectImage) => {
if (aspectImage?.aspect) {
let width: number;
let height: number;
if (aspectImage.aspect > 1) {
width = image.size;
height = image.size / aspectImage.aspect;
} else {
width = image.size * aspectImage.aspect;
height = image.size;
}
let iconAnchor = image.markerOffset;
let popupAnchor = image.tooltipOffset;
if (!iconAnchor) {
iconAnchor = [width * this.dataLayer.markerOffset[0], height * this.dataLayer.markerOffset[1]];
}
if (!popupAnchor) {
popupAnchor = [width * this.dataLayer.tooltipOffset[0], height * this.dataLayer.tooltipOffset[1]];
}
const style = `background-image: url(${aspectImage.url}); transform: rotate(${rotationAngle}deg); height: ${height}px; width: ${width}px;`;
const icon = L.divIcon({
html: `<div style="${style}"></div>`,
className: 'tb-marker-div-icon',
iconSize: [width, height],
iconAnchor,
popupAnchor
});
const iconInfo: MarkerIconInfo = {
size: [width, height],
icon
};
return of(iconInfo);
} else {
return this.dataLayer.createDefaultMarkerIcon(rotationAngle);
}
}),
catchError(() => this.dataLayer.createDefaultMarkerIcon(rotationAngle))
);
} else {
return this.dataLayer.createDefaultMarkerIcon(rotationAngle);
}
}
}
export class TbTripsDataLayer extends TbMapDataLayer<TripsDataLayerSettings, TbTripsDataLayer> {
public tripMarkerIconProcessor: TripMarkerIconProcessor<any>;
public markerOffset: L.LatLngTuple;
public tooltipOffset: L.LatLngTuple;
public pathStrokeColorProcessor: DataLayerColorProcessor;
public pointColorProcessor: DataLayerColorProcessor;
public pointTooltipProcessor: DataLayerPatternProcessor;
private positionFunction: CompiledTbFunction<MarkerPositionFunction>;
private rawTripsData: FormattedData<TbMapDatasource>[][];
private latestTripsData: FormattedData<TbMapDatasource>[];
constructor(protected map: TbMap<any>,
inputSettings: TripsDataLayerSettings) {
super(map, inputSettings);
}
public dataLayerType(): MapDataLayerType {
return MapDataLayerType.trip;
}
public placeItem(item: UnplacedMapDataItem, layer: L.Layer): void {
throw new Error('Not implemented!');
}
protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource {
datasource.dataKeys = [this.settings.xKey, this.settings.yKey];
if (this.settings.additionalDataKeys?.length) {
const tsKeys = this.settings.additionalDataKeys.filter(key => key.type === DataKeyType.timeseries);
const latestKeys = this.settings.additionalDataKeys.filter(key => key.type !== DataKeyType.timeseries);
datasource.dataKeys.push(...tsKeys);
if (latestKeys.length) {
datasource.latestDataKeys = latestKeys;
}
}
return datasource;
}
protected defaultBaseSettings(map: TbMap<any>): Partial<TripsDataLayerSettings> {
return defaultBaseTripsDataLayerSettings(map.type());
}
protected doSetup(): Observable<void> {
this.markerOffset = [
isDefined(this.settings.markerOffsetX) ? this.settings.markerOffsetX : 0.5,
isDefined(this.settings.markerOffsetY) ? this.settings.markerOffsetY : 1,
];
this.tooltipOffset = [
isDefined(this.settings.tooltip?.offsetX) ? this.settings.tooltip?.offsetX : 0,
isDefined(this.settings.tooltip?.offsetY) ? this.settings.tooltip?.offsetY : -1,
];
this.tripMarkerIconProcessor = TripMarkerIconProcessor.fromSettings(this, this.settings);
const setup$: Observable<void>[] = [];
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.tripMarkerIconProcessor.setup());
if (this.settings.showPath) {
this.pathStrokeColorProcessor = new DataLayerColorProcessor(this, this.settings.pathStrokeColor);
setup$.push(this.pathStrokeColorProcessor.setup());
}
if (this.settings.showPoints) {
this.pointColorProcessor = new DataLayerColorProcessor(this, this.settings.pointColor);
setup$.push(this.pointColorProcessor.setup());
if (this.settings.pointTooltip?.show) {
this.pointTooltipProcessor = new DataLayerPatternProcessor(this, this.settings.pointTooltip);
setup$.push(this.pointTooltipProcessor.setup());
}
}
return forkJoin(setup$).pipe(map(() => null));
}
protected isValidLayerData(_layerData: FormattedData<TbMapDatasource>): boolean {
throw new Error('Not implemented!');
}
protected createLayerItem(_data: FormattedData<TbMapDatasource>, _dsData: FormattedData<TbMapDatasource>[]): TbDataLayerItem<TripsDataLayerSettings,TbTripsDataLayer> {
throw new Error('Not implemented!');
}
public updateData(_dsData: FormattedData<TbMapDatasource>[]) {
throw new Error('Not implemented!');
}
public prepareTripsData(tripsData: FormattedData<TbMapDatasource>[][], tripsLatestData: FormattedData<TbMapDatasource>[]): {minTime: number; maxTime: number} {
let minTime = Infinity;
let maxTime = -Infinity;
this.rawTripsData =
tripsData.filter(d => d[0].$datasource.mapDataIds.includes(this.mapDataId)).map(
item => this.clearIncorrectFirsLastDatapoint(item)).filter(arr => arr.length);
this.latestTripsData = tripsLatestData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId));
this.rawTripsData.forEach((dataSource) => {
minTime = Math.min(dataSource[0].time, minTime);
maxTime = Math.max(dataSource[dataSource.length - 1].time, maxTime);
});
return {minTime, maxTime};
}
public updateTrips(minTime: number, maxTime: number) {
console.log(`Update trips: min(${minTime}), max(${maxTime})`);
}
public updateTripsLatestData(tripsLatestData: FormattedData<TbMapDatasource>[]) {
this.latestTripsData = tripsLatestData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId));
}
private clearIncorrectFirsLastDatapoint(dataSource: FormattedData<TbMapDatasource>[]): FormattedData<TbMapDatasource>[] {
const firstHistoricalDataIndexCoordinate = dataSource.findIndex(this.findFirstHistoricalDataIndexCoordinate);
if (firstHistoricalDataIndexCoordinate === -1) {
return [];
}
let lastIndex = dataSource.length - 1;
for (lastIndex; lastIndex > 0; lastIndex--) {
if (this.findFirstHistoricalDataIndexCoordinate(dataSource[lastIndex])) {
lastIndex++;
break;
}
}
if (firstHistoricalDataIndexCoordinate > 0 || lastIndex < dataSource.length) {
return dataSource.slice(firstHistoricalDataIndexCoordinate, lastIndex);
}
return dataSource;
}
private findFirstHistoricalDataIndexCoordinate = (item: FormattedData<TbMapDatasource>): boolean => {
return isDefined(item[this.settings.xKey.label]) && isDefined(item[this.settings.yKey.label]);
}
public createDefaultMarkerIcon(rotationAngle = 0): Observable<MarkerIconInfo> {
const color = this.settings.markerShape?.color?.color || '#307FE5';
return this.createColoredMarkerShape(MarkerShape.markerShape1, tinycolor(color), rotationAngle);
}
public createColoredMarkerShape(shape: MarkerShape, color: tinycolor.Instance, rotationAngle = 0, size = 34): Observable<MarkerIconInfo> {
return createColorMarkerShapeURI(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), shape, color).pipe(
map((iconUrl) => {
const style = `background-image: url(${iconUrl}); transform: rotate(${rotationAngle}deg); height: ${size}px; width: ${size}px;`;
return {
size: [size, size],
icon: L.divIcon({
html: `<div style="${style}"></div>`,
className: 'tb-marker-div-icon',
iconSize: [size, size],
iconAnchor: [size * this.markerOffset[0], size * this.markerOffset[1]],
popupAnchor: [size * this.tooltipOffset[0], size * this.tooltipOffset[1]]
})
};
})
);
}
public createColoredMarkerIcon(icon: string, color: tinycolor.Instance, rotationAngle = 0, size = 34): Observable<MarkerIconInfo> {
return createColorMarkerIconElement(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), icon, color).pipe(
map((element) => {
element.style.transform = `rotate(${rotationAngle}deg)`;
return {
size: [size, size],
icon: L.divIcon({
html: element.outerHTML,
className: 'tb-marker-div-icon',
iconSize: [size, size],
iconAnchor: [size * this.markerOffset[0], size * this.markerOffset[1]],
popupAnchor: [size * this.tooltipOffset[0], size * this.tooltipOffset[1]]
})
};
})
);
}
}

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

@ -15,7 +15,7 @@
*/
@import '../../../../../../../scss/constants';
.tb-map-layout {
.tb-map-container, .tb-map-layout {
position: relative;
display: flex;
width: 100%;
@ -23,6 +23,13 @@
min-width: 0;
min-height: 0;
flex: 1;
}
.tb-map-container {
flex-direction: column;
}
.tb-map-layout {
&.tb-sidebar-left {
flex-direction: row-reverse;
}

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

@ -28,7 +28,13 @@ import {
TbPolygonRawCoordinates
} from '@home/components/widget/lib/maps/models/map.models';
import { WidgetContext } from '@home/models/widget-component.models';
import { formattedDataFormDatasourceData, isDefinedAndNotNull, mergeDeepIgnoreArray } from '@core/utils';
import {
formattedDataArrayFromDatasourceData,
formattedDataFormDatasourceData,
isDefined,
isDefinedAndNotNull,
mergeDeepIgnoreArray
} from '@core/utils';
import { DeepPartial } from '@shared/models/common';
import L from 'leaflet';
import { forkJoin, Observable, of } from 'rxjs';
@ -61,6 +67,9 @@ import { DomSanitizer } from '@angular/platform-browser';
import tinycolor from 'tinycolor2';
import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance;
import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide;
import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/panels/map-timeline-panel.component';
import { ComponentRef } from '@angular/core';
import { TbTripsDataLayer } from '@home/components/widget/lib/maps/data-layer/trips-data-layer';
type TooltipInstancesData = {root: HTMLElement, instances: ITooltipsterInstance[]};
@ -76,11 +85,13 @@ export abstract class TbMap<S extends BaseMapSettings> {
protected southWest = new L.LatLng(-L.Projection.SphericalMercator['MAX_LATITUDE'], -180);
protected northEast = new L.LatLng(L.Projection.SphericalMercator['MAX_LATITUDE'], 180);
protected dataLayers: TbMapDataLayer<any,any>[];
protected dataLayers: TbMapDataLayer<any>[];
protected tripDataLayers: TbTripsDataLayer[];
protected dsData: FormattedData<TbMapDatasource>[];
protected selectedDataItem: TbDataLayerItem;
protected mapLayoutElement: HTMLElement;
protected mapElement: HTMLElement;
protected sidebar: L.TB.SidebarControl;
@ -93,6 +104,9 @@ export abstract class TbMap<S extends BaseMapSettings> {
protected addPolygonButton: L.TB.ToolbarButton;
protected addCircleButton: L.TB.ToolbarButton;
protected timeLineComponentRef: ComponentRef<MapTimelinePanelComponent>;
protected timeLineComponent: MapTimelinePanelComponent;
protected addMarkerDataLayers: TbMapDataLayer<any,any>[];
protected addPolygonDataLayers: TbMapDataLayer<any,any>[];
protected addCircleDataLayers: TbMapDataLayer<any,any>[];
@ -114,9 +128,27 @@ export abstract class TbMap<S extends BaseMapSettings> {
this.settings = mergeDeepIgnoreArray({} as S, this.defaultSettings(), this.inputSettings as S);
$(containerElement).empty();
$(containerElement).addClass('tb-map-layout');
$(containerElement).addClass('tb-map-container');
const mapLayoutElement = $('<div class="tb-map-layout"></div>');
this.mapLayoutElement = mapLayoutElement[0];
$(containerElement).append(mapLayoutElement);
if (this.settings.tripTimeline?.showTimelineControl) {
this.timeLineComponentRef = this.ctx.widgetContentContainer.createComponent(MapTimelinePanelComponent);
this.timeLineComponent = this.timeLineComponentRef.instance;
this.timeLineComponent.settings = this.settings.tripTimeline;
this.timeLineComponent.timeChanged.subscribe((time) => {
console.log(`Time updated: ${time}`);
});
const parentElement = this.timeLineComponentRef.instance.element.nativeElement;
const content = parentElement.firstChild;
parentElement.removeChild(content);
parentElement.style.display = 'none';
containerElement.append(content);
}
const mapElement = $('<div class="tb-map"></div>');
$(containerElement).append(mapElement);
mapLayoutElement.append(mapElement);
this.mapResize$ = new ResizeObserver(() => {
this.resize();
@ -173,6 +205,7 @@ export abstract class TbMap<S extends BaseMapSettings> {
private setupDataLayers() {
this.dataLayers = [];
this.tripDataLayers = [];
if (this.settings.markers) {
this.dataLayers.push(...this.settings.markers.map(settings => new TbMarkersDataLayer(this, settings)));
}
@ -182,10 +215,14 @@ export abstract class TbMap<S extends BaseMapSettings> {
if (this.settings.circles) {
this.dataLayers.push(...this.settings.circles.map(settings => new TbCirclesDataLayer(this, settings)));
}
if (this.dataLayers.length) {
if (this.settings.trips) {
this.tripDataLayers.push(...this.settings.trips.map(settings => new TbTripsDataLayer(this, settings)));
}
if (this.dataLayers.length || this.tripDataLayers.length) {
const groupsMap = new Map<string, L.TB.GroupData>();
const customTranslate = this.ctx.$injector.get(CustomTranslatePipe);
this.dataLayers.forEach(dl => {
const allDataLayers = [...this.dataLayers, ...this.tripDataLayers];
allDataLayers.forEach(dl => {
dl.getGroups().forEach(group => {
let groupData = groupsMap.get(group);
if (!groupData) {
@ -217,7 +254,7 @@ export abstract class TbMap<S extends BaseMapSettings> {
});
}
const setup = this.dataLayers.map(dl => dl.setup());
const setup = allDataLayers.map(dl => dl.setup());
forkJoin(setup).subscribe(
() => {
let datasources: TbMapDatasource[];
@ -231,35 +268,77 @@ export abstract class TbMap<S extends BaseMapSettings> {
}
const additionalDatasources = additionalMapDataSourcesToDatasources(this.settings.additionalDataSources);
datasources = mergeMapDatasources(datasources, additionalDatasources);
const dataLayersSubscriptionOptions: WidgetSubscriptionOptions = {
datasources,
hasDataPageLink: true,
useDashboardTimewindow: false,
type: widgetType.latest,
callbacks: {
onDataUpdated: (subscription) => {
this.update(subscription);
if (datasources.length) {
const dataLayersSubscriptionOptions: WidgetSubscriptionOptions = {
datasources,
hasDataPageLink: true,
useDashboardTimewindow: false,
type: widgetType.latest,
callbacks: {
onDataUpdated: (subscription) => {
this.update(subscription);
}
}
}
};
this.ctx.subscriptionApi.createSubscription(dataLayersSubscriptionOptions, false).subscribe(
(dataLayersSubscription) => {
let pageSize = this.settings.mapPageSize;
if (isDefinedAndNotNull(this.ctx.widgetConfig.pageSize)) {
pageSize = Math.max(pageSize, this.ctx.widgetConfig.pageSize);
};
this.ctx.subscriptionApi.createSubscription(dataLayersSubscriptionOptions, false).subscribe(
(dataLayersSubscription) => {
let pageSize = this.settings.mapPageSize;
if (isDefinedAndNotNull(this.ctx.widgetConfig.pageSize)) {
pageSize = Math.max(pageSize, this.ctx.widgetConfig.pageSize);
}
const pageLink: EntityDataPageLink = {
page: 0,
pageSize,
textSearch: null,
dynamic: true
};
dataLayersSubscription.paginatedDataSubscriptionUpdated.subscribe(() => {
// this.map.resetState();
});
dataLayersSubscription.subscribeAllForPaginatedData(pageLink, null);
}
const pageLink: EntityDataPageLink = {
page: 0,
pageSize,
textSearch: null,
dynamic: true
};
dataLayersSubscription.paginatedDataSubscriptionUpdated.subscribe(() => {
// this.map.resetState();
});
dataLayersSubscription.subscribeAllForPaginatedData(pageLink, null);
);
}
if (this.tripDataLayers.length) {
const tripDatasources = this.tripDataLayers.map(dl => dl.getDatasource());
const tripDataLayersSubscriptionOptions: WidgetSubscriptionOptions = {
datasources: tripDatasources,
hasDataPageLink: true,
useDashboardTimewindow: isDefined(this.ctx.widgetConfig.useDashboardTimewindow)
? this.ctx.widgetConfig.useDashboardTimewindow : true,
type: widgetType.timeseries,
callbacks: {
onDataUpdated: (subscription) => {
this.updateTrips(subscription);
},
onLatestDataUpdated: (subscription) => {
this.updateTripsWithLatestData(subscription);
}
}
};
if (!tripDataLayersSubscriptionOptions.useDashboardTimewindow) {
tripDataLayersSubscriptionOptions.timeWindowConfig = this.ctx.widgetConfig.timewindow;
}
);
this.ctx.subscriptionApi.createSubscription(tripDataLayersSubscriptionOptions, false).subscribe(
(tripDataLayersSubscription) => {
let pageSize = this.settings.mapPageSize;
if (isDefinedAndNotNull(this.ctx.widgetConfig.pageSize)) {
pageSize = Math.max(pageSize, this.ctx.widgetConfig.pageSize);
}
const pageLink: EntityDataPageLink = {
page: 0,
pageSize,
textSearch: null,
dynamic: true
};
tripDataLayersSubscription.paginatedDataSubscriptionUpdated.subscribe(() => {
// this.map.resetState();
});
tripDataLayersSubscription.subscribeAllForPaginatedData(pageLink, null);
}
);
}
}
);
}
@ -582,10 +661,51 @@ export abstract class TbMap<S extends BaseMapSettings> {
this.dsData = formattedDataFormDatasourceData<TbMapDatasource>(subscription.data,
undefined, undefined, el => el.datasource.entityId + el.datasource.mapDataIds[0]);
this.dataLayers.forEach(dl => dl.updateData(this.dsData));
this.updateTripsAppearance();
this.updateTripsTimeline();
this.updateBounds();
this.updateAddButtonsStates();
}
private updateTrips(subscription: IWidgetSubscription) {
const tripsData = formattedDataArrayFromDatasourceData<TbMapDatasource>(subscription.data, el => el.datasource.entityId + el.datasource.mapDataIds[0]);
const tripsLatestData = formattedDataFormDatasourceData<TbMapDatasource>(subscription.latestData,
undefined, undefined, el => el.datasource.entityId + el.datasource.mapDataIds[0]);
let minTime = Infinity;
let maxTime = -Infinity;
for (const tripsDataLayer of this.tripDataLayers) {
const minMax = tripsDataLayer.prepareTripsData(tripsData, tripsLatestData);
minTime = Math.min(minMax.minTime, minTime);
maxTime = Math.max(minMax.maxTime, maxTime);
}
this.tripDataLayers.forEach(dl => dl.updateTrips(minTime, maxTime));
this.updateTripsTimeline(minTime, maxTime);
this.updateBounds();
}
private updateTripsWithLatestData(subscription: IWidgetSubscription) {
const tripsLatestData = formattedDataFormDatasourceData<TbMapDatasource>(subscription.latestData,
undefined, undefined, el => el.datasource.entityId + el.datasource.mapDataIds[0]);
this.tripDataLayers.forEach(dl => dl.updateTripsLatestData(tripsLatestData));
this.updateTripsAppearance();
this.updateTripsTimeline();
}
private updateTripsAppearance() {}
private updateTripsTimeline(minTime?: number, maxTime?: number) {
if (this.settings.tripTimeline?.showTimelineControl) {
if (isDefinedAndNotNull(minTime) && isDefinedAndNotNull(maxTime)) {
this.timeLineComponent.min = minTime;
this.timeLineComponent.max = maxTime;
}
if (this.settings.tripTimeline.snapToRealLocation) {
// Recalculate anchors only for enabled layers
}
}
}
private resize() {
this.onResize();
this.map?.invalidateSize();
@ -659,7 +779,7 @@ export abstract class TbMap<S extends BaseMapSettings> {
protected getSidebar(): L.TB.SidebarControl {
if (!this.sidebar) {
this.sidebar = L.TB.sidebar({
container: $(this.containerElement),
container: $(this.mapLayoutElement),
position: this.settings.controlsPosition,
paneWidth: 220
}).addTo(this.map);
@ -685,6 +805,7 @@ export abstract class TbMap<S extends BaseMapSettings> {
public enabledDataLayersUpdated() {
this.updateAddButtonsStates();
this.updateTripsTimeline();
}
public dataItemClick($event: Event, action: WidgetAction, entityInfo: TbMapDatasource) {
@ -787,6 +908,9 @@ export abstract class TbMap<S extends BaseMapSettings> {
instance.destroy();
})
});
if (this.timeLineComponentRef) {
this.timeLineComponentRef.destroy();
}
}
public abstract locationDataToLatLng(position: {x: number; y: number}): L.LatLng;

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

@ -33,6 +33,7 @@ import { map } from 'rxjs/operators';
import { ImagePipe } from '@shared/pipe/image.pipe';
import { MarkerShape } from '@home/components/widget/lib/maps/models/marker-shape.models';
import { UnplacedMapDataItem } from '@home/components/widget/lib/maps/data-layer/map-data-layer';
import { DateFormatSettings, simpleDateFormat } from '@shared/models/widget-settings.models';
export enum MapType {
geoMap = 'geoMap',
@ -368,6 +369,83 @@ export const defaultBaseMarkersDataLayerSettings = (mapType: MapType): Partial<M
}
} as MarkersDataLayerSettings, defaultBaseDataLayerSettings(mapType));
export enum PathDecoratorSymbol {
arrowHead = 'arrowHead',
dash = 'dash'
}
export const pathDecoratorSymbols = Object.keys(PathDecoratorSymbol) as PathDecoratorSymbol[];
export const pathDecoratorSymbolTranslationMap = new Map<PathDecoratorSymbol, string>(
[
[PathDecoratorSymbol.arrowHead, 'widgets.maps.data-layer.decorator-symbol-arrow-head'],
[PathDecoratorSymbol.dash, 'widgets.maps.data-layer.decorator-symbol-dash']
]
);
export interface TripsDataLayerSettings extends MarkersDataLayerSettings {
rotateMarker: boolean;
offsetAngle: number;
showPath: boolean;
pathStrokeWeight?: number;
pathStrokeColor?: DataLayerColorSettings;
usePathDecorator?: boolean;
pathDecoratorSymbol?: PathDecoratorSymbol;
pathDecoratorSymbolSize?: number;
pathDecoratorSymbolColor?: string;
pathDecoratorOffset?: number;
pathEndDecoratorOffset?: number;
pathDecoratorRepeat?: number;
showPoints: boolean;
pointSize?: number;
pointColor?: DataLayerColorSettings;
pointTooltip?: DataLayerTooltipSettings;
}
export const defaultBaseTripsDataLayerSettings = (mapType: MapType): Partial<TripsDataLayerSettings> => mergeDeep(
defaultBaseMarkersDataLayerSettings(mapType),
{
tooltip: {
pattern: mapType === MapType.geoMap ?
'<b>${entityName}</b><br/><br/><b>Latitude:</b> ${latitude:7}<br/><b>Longitude:</b> ${longitude:7}<br/><b>End Time:</b> ${maxTime}<br/><b>Start Time:</b> ${minTime}'
: '<b>${entityName}</b><br/><br/><b>X Pos:</b> ${xPos:2}<br/><b>Y Pos:</b> ${yPos:2}<br/><b>End Time:</b> ${maxTime}<br/><b>Start Time:</b> ${minTime}',
},
rotateMarker: true,
offsetAngle: 0,
markerOffsetX: 0.5,
markerOffsetY: 0.5,
showPath: true,
pathStrokeWeight: 2,
pathStrokeColor: {
type: DataLayerColorType.constant,
color: '#307FE5',
},
usePathDecorator: false,
pathDecoratorSymbol: PathDecoratorSymbol.arrowHead,
pathDecoratorSymbolSize: 10,
pathDecoratorSymbolColor: '#307FE5',
pathDecoratorOffset: 20,
pathEndDecoratorOffset: 20,
pathDecoratorRepeat: 20,
showPoints: false,
pointSize: 10,
pointColor: {
type: DataLayerColorType.constant,
color: '#307FE5',
},
pointTooltip: {
show: true,
trigger: DataLayerTooltipTrigger.click,
autoclose: true,
type: DataLayerPatternType.pattern,
pattern: mapType === MapType.geoMap ?
'<b>${entityName}</b><br/><br/><b>Latitude:</b> ${latitude:7}<br/><b>Longitude:</b> ${longitude:7}<br/><b>End Time:</b> ${maxTime}<br/><b>Start Time:</b> ${minTime}'
: '<b>${entityName}</b><br/><br/><b>X Pos:</b> ${xPos:2}<br/><b>Y Pos:</b> ${yPos:2}<br/><b>End Time:</b> ${maxTime}<br/><b>Start Time:</b> ${minTime}',
offsetX: 0,
offsetY: -1
},
} as TripsDataLayerSettings);
export interface ShapeDataLayerSettings extends MapDataLayerSettings {
fillColor: DataLayerColorSettings;
strokeColor: DataLayerColorSettings;
@ -525,8 +603,19 @@ export const mapZoomActionTranslationMap = new Map<MapZoomAction, string>(
]
);
export interface TripTimelineSettings {
showTimelineControl: boolean;
timeStep: number;
speedOptions: number[];
showTimestamp: boolean;
timestampFormat: DateFormatSettings;
snapToRealLocation: boolean;
locationSnapFilter: TbFunction;
}
export interface BaseMapSettings {
mapType: MapType;
trips?: TripsDataLayerSettings[];
markers: MarkersDataLayerSettings[];
polygons: PolygonsDataLayerSettings[];
circles: CirclesDataLayerSettings[];
@ -539,6 +628,7 @@ export interface BaseMapSettings {
defaultZoomLevel: number;
minZoomLevel: number;
mapPageSize: number;
tripTimeline?: TripTimelineSettings;
}
export const DEFAULT_MAP_PAGE_SIZE = 16384;
@ -546,6 +636,7 @@ export const DEFAULT_ZOOM_LEVEL = 8;
export const defaultBaseMapSettings: BaseMapSettings = {
mapType: MapType.geoMap,
trips: [],
markers: [],
polygons: [],
circles: [],
@ -557,7 +648,16 @@ export const defaultBaseMapSettings: BaseMapSettings = {
defaultCenterPosition: '0,0',
defaultZoomLevel: null,
minZoomLevel: 16,
mapPageSize: DEFAULT_MAP_PAGE_SIZE
mapPageSize: DEFAULT_MAP_PAGE_SIZE,
tripTimeline: {
showTimelineControl: false,
timeStep: 1000,
speedOptions: [1,5,10,15,25],
showTimestamp: true,
timestampFormat: simpleDateFormat('yyyy-MM-dd HH:mm:ss'),
snapToRealLocation: false,
locationSnapFilter: 'return true;'
}
};
export enum MapProvider {

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

@ -0,0 +1,26 @@
<!--
Copyright © 2016-2025 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="tb-map-timeline-panel">
<mat-slider
[disabled]="disabled"
[min]="min"
[max]="max"
[step]="settings.timeStep">
<input matSliderThumb [(ngModel)]="currentTime" (valueChange)="onTimeChange()">
</mat-slider>
</div>

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

@ -0,0 +1,19 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.tb-map-timeline-panel {
display: flex;
flex-direction: column;
}

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

@ -0,0 +1,68 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
Component,
ElementRef,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewEncapsulation
} from '@angular/core';
import { TripTimelineSettings } from '@home/components/widget/lib/maps/models/map.models';
@Component({
selector: 'tb-map-timeline-panel',
templateUrl: './map-timeline-panel.component.html',
styleUrls: ['./map-timeline-panel.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class MapTimelinePanelComponent implements OnInit, OnDestroy {
@Input()
settings: TripTimelineSettings;
@Input()
disabled = false;
@Input()
min = 0;
@Input()
max = 10000;
@Output()
timeChanged = new EventEmitter<number>();
currentTime = 0;
constructor(public element: ElementRef<HTMLElement>) {
}
ngOnInit() {
}
ngOnDestroy() {
}
public onTimeChange() {
//this.updateValueText();
this.timeChanged.next(this.currentTime);
}
}

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

@ -79,6 +79,10 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid
MapType = MapType;
@Input()
@coerceBoolean()
trip = false;
@Input()
disabled: boolean;

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

@ -93,6 +93,7 @@ import { MapWidgetComponent } from '@home/components/widget/lib/maps/map-widget.
import {
SelectMapEntityPanelComponent
} from '@home/components/widget/lib/maps/panels/select-map-entity-panel.component';
import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/panels/map-timeline-panel.component';
@NgModule({
declarations: [
@ -149,6 +150,7 @@ import {
NotificationTypeFilterPanelComponent,
ScadaSymbolWidgetComponent,
SelectMapEntityPanelComponent,
MapTimelinePanelComponent,
MapWidgetComponent
],
imports: [

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

@ -278,6 +278,12 @@
</div>
</ng-template>
<ng-template #appearance>
<ng-container *ngIf="!displayData && displayTimewindowConfig" [formGroup]="dataSettings">
<tb-timewindow-config-panel
[onlyHistoryTimewindow]="onlyHistoryTimewindow()"
formControlName="timewindowConfig">
</tb-timewindow-config-panel>
</ng-container>
<div *ngIf="displayAppearanceDataSettings" class="tb-form-panel" [formGroup]="widgetSettings">
<div class="tb-form-panel-title" translate>widget-config.data-settings</div>
<div *ngIf="displayUnitsConfig" class="tb-form-row space-between">

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

@ -7858,6 +7858,10 @@
"place-marker": "Place marker",
"place-marker-hint": "Click to place '{{entityName}}' entity"
},
"path": {
"decorator-symbol-arrow-head": "Arrow",
"decorator-symbol-dash": "Dash"
},
"polygon": {
"polygon-key": "Polygon key",
"polygon-key-required": "Polygon key required",

Loading…
Cancel
Save