Browse Source

UI: Trip Map - Implement timeline control. Implement trip item.

pull/12807/head
Igor Kulikov 2 years ago
parent
commit
cd3a7c4c4e
  1. 96
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts
  2. 67
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts
  3. 347
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts
  4. 106
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts
  5. 52
      ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts
  6. 74
      ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html
  7. 29
      ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss
  8. 174
      ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts
  9. 3
      ui-ngx/src/assets/locale/locale.constant-en_US.json

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

@ -0,0 +1,96 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
DataLayerTooltipSettings,
DataLayerTooltipTrigger, processTooltipTemplate,
TbMapDatasource
} from '@home/components/widget/lib/maps/models/map.models';
import { TbMap } from '@home/components/widget/lib/maps/map';
import { FormattedData } from '@shared/models/widget.models';
import L from 'leaflet';
import { DataLayerPatternProcessor } from '@home/components/widget/lib/maps/data-layer/map-data-layer';
export const createTooltip = (map: TbMap<any>,
layer: L.Layer,
settings: DataLayerTooltipSettings,
data: FormattedData<TbMapDatasource>,
canOpen: () => boolean): L.Popup => {
const tooltip = L.popup();
layer.bindPopup(tooltip, {autoClose: settings.autoclose, closeOnClick: false});
layer.off('click');
if (settings.trigger === DataLayerTooltipTrigger.click) {
layer.on('click', () => {
if (tooltip.isOpen()) {
layer.closePopup();
} else if (canOpen()) {
layer.openPopup();
}
});
} else if (settings.trigger === DataLayerTooltipTrigger.hover) {
layer.on('mouseover', () => {
if (canOpen()) {
layer.openPopup();
}
});
layer.on('mousemove', (e) => {
tooltip.setLatLng(e.latlng);
});
layer.on('mouseout', () => {
layer.closePopup();
});
}
layer.on('popupopen', () => {
bindTooltipActions(map, tooltip, settings, data);
(layer as any)._popup._closeButton.addEventListener('click', (event: Event) => {
event.preventDefault();
});
});
return tooltip;
}
export const updateTooltip = (map: TbMap<any>,
tooltip: L.Popup,
settings: DataLayerTooltipSettings,
processor: DataLayerPatternProcessor,
data: FormattedData<TbMapDatasource>,
dsData: FormattedData<TbMapDatasource>[]): void => {
let tooltipTemplate = processor.processPattern(data, dsData);
tooltipTemplate = processTooltipTemplate(tooltipTemplate);
tooltip.setContent(tooltipTemplate);
if (tooltip.isOpen() && tooltip.getElement()) {
bindTooltipActions(map, tooltip, settings, data);
}
}
const bindTooltipActions = (map: TbMap<any>, tooltip: L.Popup, settings: DataLayerTooltipSettings, data: FormattedData<TbMapDatasource>): void => {
const actions = tooltip.getElement().getElementsByClassName('tb-custom-action');
Array.from(actions).forEach(
(element: HTMLElement) => {
const actionName = element.getAttribute('data-action-name');
if (settings?.tagActions) {
const action = settings.tagActions.find(action => action.name === actionName);
if (action) {
element.onclick = ($event) =>
{
map.dataItemClick($event, action, data.$datasource);
return false;
};
}
}
}
);
}

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

@ -44,6 +44,7 @@ import { CompiledTbFunction } from '@shared/models/js-function.models';
import { map } from 'rxjs/operators';
import { WidgetContext } from '@home/models/widget-component.models';
import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe';
import { createTooltip, updateTooltip } from './data-layer-utils';
export abstract class TbDataLayerItem<S extends MapDataLayerSettings = MapDataLayerSettings,
D extends TbMapDataLayer<S,D> = TbMapDataLayer<any, any>, L extends L.Layer = L.Layer> {
@ -60,8 +61,12 @@ export abstract class TbDataLayerItem<S extends MapDataLayerSettings = MapDataLa
this.data = data;
this.layer = this.create(data, dsData);
if (this.settings.tooltip?.show) {
this.createTooltip();
this.updateTooltip(data, dsData);
this.tooltip = createTooltip(this.dataLayer.getMap(),
this.layer, this.settings.tooltip, this.data, () => {
return !this.isEditing();
});
updateTooltip(this.dataLayer.getMap(), this.tooltip,
this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, data, dsData);
}
this.bindEvents();
try {
@ -220,12 +225,8 @@ export abstract class TbDataLayerItem<S extends MapDataLayerSettings = MapDataLa
protected updateTooltip(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) {
if (this.settings.tooltip.show) {
let tooltipTemplate = this.dataLayer.dataLayerTooltipProcessor.processPattern(data, dsData);
tooltipTemplate = processTooltipTemplate(tooltipTemplate);
this.tooltip.setContent(tooltipTemplate);
if (this.tooltip.isOpen() && this.tooltip.getElement()) {
this.bindTooltipActions();
}
updateTooltip(this.dataLayer.getMap(), this.tooltip,
this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, data, dsData);
}
}
@ -253,56 +254,6 @@ export abstract class TbDataLayerItem<S extends MapDataLayerSettings = MapDataLa
protected abstract removeDataItem(): Observable<any>;
private createTooltip() {
this.tooltip = L.popup();
this.layer.bindPopup(this.tooltip, {autoClose: this.settings.tooltip.autoclose, closeOnClick: false});
this.layer.off('click');
if (this.settings.tooltip.trigger === DataLayerTooltipTrigger.click) {
this.layer.on('click', () => {
if (this.tooltip.isOpen()) {
this.layer.closePopup();
} else if (!this.isEditing()) {
this.layer.openPopup();
}
});
} else if (this.settings.tooltip.trigger === DataLayerTooltipTrigger.hover) {
this.layer.on('mouseover', () => {
if (!this.isEditing()) {
this.layer.openPopup();
}
});
this.layer.on('mousemove', (e) => {
this.tooltip.setLatLng(e.latlng);
});
this.layer.on('mouseout', () => {
this.layer.closePopup();
});
}
this.layer.on('popupopen', () => {
this.bindTooltipActions();
(this.layer as any)._popup._closeButton.addEventListener('click', (event: Event) => {
event.preventDefault();
});
});
}
private bindTooltipActions() {
const actions = this.tooltip.getElement().getElementsByClassName('tb-custom-action');
Array.from(actions).forEach(
(element: HTMLElement) => {
const actionName = element.getAttribute('data-action-name');
if (this.settings.tooltip?.tagActions) {
const action = this.settings.tooltip.tagActions.find(action => action.name === actionName);
if (action) {
element.onclick = ($event) =>
{
this.dataLayer.getMap().dataItemClick($event, action, this.data.$datasource);
return false;
};
}
}
});
}
}
export enum MapDataLayerType {

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

@ -22,17 +22,25 @@ import {
UnplacedMapDataItem
} from '@home/components/widget/lib/maps/data-layer/map-data-layer';
import {
BaseMarkerShapeSettings, ClusterMarkerColorFunction,
DataLayerColorType, defaultBaseMarkersDataLayerSettings, defaultBaseTripsDataLayerSettings,
BaseMarkerShapeSettings,
calculateInterpolationRatio, calculateLastPoints,
ClusterMarkerColorFunction,
DataLayerColorType,
defaultBaseMarkersDataLayerSettings,
defaultBaseTripsDataLayerSettings,
findRotationAngle,
interpolateLineSegment, isValidLatLng,
loadImageWithAspect,
MapStringFunction, MapType,
MapStringFunction,
MapType,
MarkerIconInfo,
MarkerIconSettings,
MarkerImageFunction,
MarkerImageInfo,
MarkerImageSettings,
MarkerImageType,
MarkerPositionFunction, MarkersDataLayerSettings,
MarkerPositionFunction,
MarkersDataLayerSettings,
MarkerShapeSettings,
MarkerType,
TbMapDatasource,
@ -49,12 +57,244 @@ 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 L, { PolylineDecorator } from 'leaflet';
import { CompiledTbFunction } from '@shared/models/js-function.models';
import { isDefined, isDefinedAndNotNull, parseTbFunction, safeExecuteTbFunction } from '@core/utils';
import {
deepClone,
isDefined,
isDefinedAndNotNull,
isEmptyStr,
isUndefined,
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';
import moment from 'moment/moment';
import { TbImageMap } from '@home/components/widget/lib/maps/image-map';
import { createTooltip, updateTooltip } from '@home/components/widget/lib/maps/data-layer/data-layer-utils';
type TripRouteData = {[time: number]: FormattedData<TbMapDatasource>};
class TbTripDataItem {
private tripRouteData: TripRouteData;
private layer: L.FeatureGroup;
private marker: L.Marker;
private markerTooltip: L.Popup;
private labelOffset: L.PointTuple;
private polyline: L.Polyline;
private polylineDecorator: PolylineDecorator;
private points: L.FeatureGroup;
private currentTime: number;
private currentPositionData: FormattedData<TbMapDatasource>;
constructor(private rawRouteData: FormattedData<TbMapDatasource>[],
private latestData: FormattedData<TbMapDatasource>,
private settings: TripsDataLayerSettings,
private dataLayer: TbTripsDataLayer) {
this.tripRouteData = this.prepareTripRouteData();
this.create();
}
private create() {
this.updateCurrentPosition();
this.layer = L.featureGroup();
let pointData = this.currentPositionData;
if (this.latestData) {
pointData = {...pointData, ...this.latestData};
}
this.createMarker(pointData);
try {
this.dataLayer.getDataLayerContainer().addLayer(this.layer);
} catch (e) {
console.warn(e);
}
}
public update(rawRouteData: FormattedData<TbMapDatasource>[]) {
this.rawRouteData = rawRouteData;
this.tripRouteData = this.prepareTripRouteData();
this.updateCurrentPosition(true);
let pointData = this.currentPositionData;
if (this.latestData) {
pointData = {...pointData, ...this.latestData};
}
this.updateMarker(pointData);
}
public updateLatestData(latestData: FormattedData<TbMapDatasource>) {
this.latestData = latestData;
this.updateAppearance();
}
public updateAppearance() {
let data = this.currentPositionData;
if (this.latestData) {
data = {...data, ...this.latestData};
}
const dsData = this.dataLayer.getMap().getData();
if (this.settings.tooltip.show) {
updateTooltip(this.dataLayer.getMap(), this.markerTooltip,
this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, data, dsData);
}
this.updateMarkerIcon(data, dsData);
}
public updateCurrentTime() {
this.updateCurrentPosition();
let pointData = this.currentPositionData;
if (this.latestData) {
pointData = {...pointData, ...this.latestData};
}
this.updateMarker(pointData);
}
public remove() {
this.dataLayer.getDataLayerContainer().removeLayer(this.layer);
this.layer.off();
}
public getLayer(): L.Layer {
return this.layer;
}
private createMarker(data: FormattedData<TbMapDatasource>) {
const dsData = this.dataLayer.getMap().getData();
const location = this.dataLayer.extractLocation(data, dsData);
this.marker = L.marker(location, {
tbMarkerData: data,
snapIgnore: true
});
this.marker.addTo(this.layer);
this.updateMarkerIcon(data, dsData);
if (this.settings.tooltip?.show) {
this.markerTooltip = createTooltip(this.dataLayer.getMap(),
this.marker, this.settings.tooltip, data, () => true);
updateTooltip(this.dataLayer.getMap(), this.markerTooltip,
this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, data, dsData);
}
}
private updateMarker(data: FormattedData<TbMapDatasource>) {
const dsData = this.dataLayer.getMap().getData();
this.marker.options.tbMarkerData = data;
this.updateMarkerLocation(data, dsData);
if (this.settings.tooltip.show) {
updateTooltip(this.dataLayer.getMap(), this.markerTooltip,
this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, data, dsData);
}
this.updateMarkerIcon(data, dsData);
}
private updateMarkerLocation(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) {
const location = this.dataLayer.extractLocation(data, dsData);
if (!this.marker.getLatLng().equals(location)) {
this.marker.setLatLng(location);
}
}
private updateMarkerIcon(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) {
this.dataLayer.tripMarkerIconProcessor.createMarkerIcon(data, dsData).subscribe(
(iconInfo) => {
const options = deepClone(iconInfo.icon.options);
this.marker.setIcon(iconInfo.icon);
const anchor = options.iconAnchor;
if (anchor && Array.isArray(anchor)) {
this.labelOffset = [iconInfo.size[0] / 2 - anchor[0], 10 - anchor[1]];
} else {
this.labelOffset = [0, -iconInfo.size[1] * this.dataLayer.markerOffset[1] + 10];
}
this.updateMarkerLabel(data, dsData);
}
);
}
private updateMarkerLabel(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]) {
if (this.settings.label.show) {
this.marker.unbindTooltip();
const label = this.dataLayer.dataLayerLabelProcessor.processPattern(data, dsData);
const labelColor = this.dataLayer.getCtx().widgetConfig.color;
const content: L.Content = `<div style="color: ${labelColor};"><b>${label}</b></div>`;
this.marker.bindTooltip(content, { className: 'tb-marker-label', permanent: true, direction: 'top', offset: this.labelOffset });
}
}
private prepareTripRouteData(): TripRouteData {
const result: TripRouteData = {};
const minTime = this.dataLayer.getMap().getMinTime();
const maxTime = this.dataLayer.getMap().getMaxTime();
const timeStep = this.dataLayer.getMap().getTimeStep();
const timeline = this.dataLayer.getMap().hasTimeline();
for (const data of this.rawRouteData) {
const currentTime = data.time;
const normalizeTime = timeline ? (minTime
+ Math.ceil((currentTime - minTime) / timeStep) * timeStep) : currentTime;
result[normalizeTime] = {
...data,
minTime: minTime !== Infinity ? moment(minTime).format('YYYY-MM-DD HH:mm:ss') : '',
maxTime: maxTime !== -Infinity ? moment(maxTime).format('YYYY-MM-DD HH:mm:ss') : '',
rotationAngle: this.settings.rotateMarker ? this.settings.offsetAngle : 0
};
}
if (timeline) {
const xKey = this.settings.xKey.label;
const yKey = this.settings.yKey.label;
const timeStamp = Object.keys(result);
for (let i = 0; i < timeStamp.length - 1; i++) {
if (isUndefined(result[timeStamp[i + 1]][xKey]) || isUndefined(result[timeStamp[i + 1]][yKey])) {
for (let j = i + 2; j < timeStamp.length - 1; j++) {
if (isDefined(result[timeStamp[j]][xKey]) || isDefined(result[timeStamp[j]][yKey])) {
const ratio = calculateInterpolationRatio(Number(timeStamp[i]), Number(timeStamp[j]), Number(timeStamp[i + 1]));
result[timeStamp[i + 1]] = {
...interpolateLineSegment(result[timeStamp[i]], result[timeStamp[j]], xKey, yKey, ratio),
...result[timeStamp[i + 1]],
};
break;
}
}
}
if (this.settings.rotateMarker) {
result[timeStamp[i]].rotationAngle += findRotationAngle(result[timeStamp[i]], result[timeStamp[i + 1]], xKey, yKey);
}
}
}
return result;
}
private updateCurrentPosition(force = false) {
if (this.currentTime !== this.dataLayer.getMap().getCurrentTime() || force) {
this.currentTime = this.dataLayer.getMap().getCurrentTime();
let currentPosition = this.tripRouteData[this.currentTime];
if (!currentPosition) {
const timePoints = Object.keys(this.tripRouteData).map(item => parseInt(item, 10));
for (let i = 1; i < timePoints.length; i++) {
if (timePoints[i - 1] < this.currentTime && timePoints[i] > this.currentTime) {
const beforePosition = this.tripRouteData[timePoints[i - 1]];
const afterPosition = this.tripRouteData[timePoints[i]];
const ratio = calculateInterpolationRatio(timePoints[i - 1], timePoints[i], this.currentTime);
currentPosition = {
...beforePosition,
time: this.currentTime,
...interpolateLineSegment(beforePosition, afterPosition, this.settings.xKey.label, this.settings.yKey.label, ratio)
};
break;
}
}
}
if (!currentPosition) {
currentPosition = calculateLastPoints(this.tripRouteData, this.currentTime);
}
this.currentPositionData = currentPosition;
}
}
}
abstract class TripMarkerIconProcessor<S> {
@ -205,7 +445,7 @@ class ImageTripMarkerIconProcessor extends TripMarkerIconProcessor<MarkerImageSe
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 style = `background-image: url(${aspectImage.url}); background-size: contain; transform: rotate(${rotationAngle}deg); height: ${height}px; width: ${width}px;`;
const icon = L.divIcon({
html: `<div style="${style}"></div>`,
className: 'tb-marker-div-icon',
@ -241,6 +481,8 @@ export class TbTripsDataLayer extends TbMapDataLayer<TripsDataLayerSettings, TbT
public pointColorProcessor: DataLayerColorProcessor;
public pointTooltipProcessor: DataLayerPatternProcessor;
private tripItems = new Map<string,TbTripDataItem>();
private positionFunction: CompiledTbFunction<MarkerPositionFunction>;
private rawTripsData: FormattedData<TbMapDatasource>[][];
@ -338,16 +580,66 @@ export class TbTripsDataLayer extends TbMapDataLayer<TripsDataLayerSettings, TbT
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 updateTrips() {
const minTime = this.map.getMinTime();
const maxTime = this.map.getMaxTime();
const currentTime = this.map.getCurrentTime();
console.log(`Update trips: min(${minTime}), max(${maxTime}), current(${currentTime})`);
const toDelete = new Set(Array.from(this.tripItems.keys()));
this.rawTripsData.forEach(rawTripData => {
const entityId = rawTripData[0].entityId;
let tripItem = this.tripItems.get(entityId);
if (tripItem) {
tripItem.update(rawTripData);
} else {
const latestData = this.latestTripsData.find(d => d.entityId === entityId);
tripItem = new TbTripDataItem(rawTripData, latestData, this.settings, this);
this.tripItems.set(entityId, tripItem);
}
toDelete.delete(entityId);
});
toDelete.forEach((key) => {
this.removeItem(key);
});
}
public removeItem(key: string): void {
const item = this.tripItems.get(key);
if (item) {
item.remove();
this.tripItems.delete(key);
}
}
public updateTripsLatestData(tripsLatestData: FormattedData<TbMapDatasource>[]) {
this.latestTripsData = tripsLatestData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId));
console.log(`Update trips latest data`);
this.tripItems.forEach((item, entityId) => {
const latestData = this.latestTripsData.find(d => d.entityId === entityId);
item.updateLatestData(latestData);
});
}
public updateCurrentTime() {
const currentTime = this.map.getCurrentTime();
console.log(`Update trips current time: current(${currentTime})`);
this.tripItems.forEach(item => {
item.updateCurrentTime();
});
}
public updateAppearance() {
console.log(`Update trips appearance`);
this.tripItems.forEach(item => {
item.updateAppearance();
});
}
public calculateAnchors(): number[] {
return [];
}
private clearIncorrectFirsLastDatapoint(dataSource: FormattedData<TbMapDatasource>[]): FormattedData<TbMapDatasource>[] {
@ -413,4 +705,39 @@ export class TbTripsDataLayer extends TbMapDataLayer<TripsDataLayerSettings, TbT
);
}
private extractLocationData(data: FormattedData<TbMapDatasource>): {x: number; y: number} {
if (data) {
const xKeyVal = data[this.settings.xKey.label];
const yKeyVal = data[this.settings.yKey.label];
switch (this.mapType()) {
case MapType.geoMap:
if (!isValidLatLng(xKeyVal, yKeyVal)) {
return null;
}
break;
case MapType.image:
if (!isDefinedAndNotNull(xKeyVal) || isEmptyStr(xKeyVal) || isNaN(xKeyVal) || !isDefinedAndNotNull(yKeyVal) || isEmptyStr(yKeyVal) || isNaN(yKeyVal)) {
return null;
}
break;
}
return {x: xKeyVal, y: yKeyVal};
} else {
return null;
}
}
public extractLocation(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): L.LatLng {
let locationData = this.extractLocationData(data);
if (locationData) {
if (this.map.type() === MapType.image && this.positionFunction) {
const imageMap = this.map as TbImageMap;
locationData = this.positionFunction.execute(locationData.x, locationData.y, data, dsData, imageMap.getAspect()) || {x: 0, y: 0};
}
return this.map.locationDataToLatLng(locationData);
} else {
return null;
}
}
}

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

@ -33,7 +33,7 @@ import {
formattedDataArrayFromDatasourceData,
formattedDataFormDatasourceData,
isDefined,
isDefinedAndNotNull,
isDefinedAndNotNull, isUndefined,
mergeDeepIgnoreArray
} from '@core/utils';
import { DeepPartial } from '@shared/models/common';
@ -90,6 +90,12 @@ export abstract class TbMap<S extends BaseMapSettings> {
protected tripDataLayers: TbTripsDataLayer[];
protected dsData: FormattedData<TbMapDatasource>[];
protected timeline = false;
protected minTime: number;
protected maxTime: number;
protected timeStep: number;
protected currentTime: number;
protected selectedDataItem: TbDataLayerItem;
protected mapLayoutElement: HTMLElement;
@ -108,9 +114,9 @@ export abstract class TbMap<S extends BaseMapSettings> {
protected timeLineComponentRef: ComponentRef<MapTimelinePanelComponent>;
protected timeLineComponent: MapTimelinePanelComponent;
protected addMarkerDataLayers: TbMapDataLayer<any,any>[];
protected addPolygonDataLayers: TbMapDataLayer<any,any>[];
protected addCircleDataLayers: TbMapDataLayer<any,any>[];
protected addMarkerDataLayers: TbMapDataLayer<any>[];
protected addPolygonDataLayers: TbMapDataLayer<any>[];
protected addCircleDataLayers: TbMapDataLayer<any>[];
private readonly mapResize$: ResizeObserver;
@ -135,11 +141,14 @@ export abstract class TbMap<S extends BaseMapSettings> {
$(containerElement).append(mapLayoutElement);
if (this.settings.tripTimeline?.showTimelineControl) {
this.timeline = true;
this.timeStep = this.settings.tripTimeline.timeStep;
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}`);
this.currentTime = time;
this.updateTripsTime();
});
const parentElement = this.timeLineComponentRef.instance.element.nativeElement;
const content = parentElement.firstChild;
@ -740,7 +749,7 @@ export abstract class TbMap<S extends BaseMapSettings> {
undefined, undefined, el => el.datasource.entityId + el.datasource.mapDataIds[0]);
this.dataLayers.forEach(dl => dl.updateData(this.dsData));
this.updateTripsAppearance();
this.updateTripsTimeline();
this.updateTripsAnchors();
this.updateBounds();
this.updateAddButtonsStates();
}
@ -757,8 +766,23 @@ export abstract class TbMap<S extends BaseMapSettings> {
minTime = Math.min(minMax.minTime, minTime);
maxTime = Math.max(minMax.maxTime, maxTime);
}
this.tripDataLayers.forEach(dl => dl.updateTrips(minTime, maxTime));
this.updateTripsTimeline(minTime, maxTime);
const prevMinTime = this.minTime;
const prevMaxTime = this.maxTime;
this.minTime = minTime;
this.maxTime = maxTime;
if (this.timeline) {
this.timeLineComponent.min = this.minTime;
this.timeLineComponent.max = this.maxTime;
const currentTime = this.calculateCurrentTime(prevMinTime, prevMaxTime);
if (currentTime !== this.currentTime) {
this.currentTime = currentTime;
this.timeLineComponent.currentTime = currentTime;
}
} else {
this.currentTime = this.maxTime;
}
this.tripDataLayers.forEach(dl => dl.updateTrips());
this.updateTripsAnchors();
this.updateBounds();
}
@ -766,22 +790,43 @@ export abstract class TbMap<S extends BaseMapSettings> {
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();
this.updateTripsAnchors();
}
private updateTripsAppearance() {}
private updateTripsAppearance() {
this.tripDataLayers.forEach(dl => dl.updateAppearance());
}
private updateTripsTime() {
this.tripDataLayers.forEach(dl => dl.updateCurrentTime());
}
private updateTripsTimeline(minTime?: number, maxTime?: number) {
if (this.settings.tripTimeline?.showTimelineControl) {
if (isDefinedAndNotNull(minTime) && isDefinedAndNotNull(maxTime)) {
this.timeLineComponent.min = minTime;
this.timeLineComponent.max = maxTime;
}
private updateTripsAnchors() {
if (this.timeline) {
if (this.settings.tripTimeline.snapToRealLocation) {
// Recalculate anchors only for enabled layers
let anchors: number[] = [];
const enableTrips = this.tripDataLayers.filter(dl => dl.isEnabled());
for (const tripsDataLayer of enableTrips) {
const tripsAnchors = tripsDataLayer.calculateAnchors();
anchors = [...new Set([...anchors, ...tripsAnchors])];
}
anchors.sort((a, b) => a - b);
this.timeLineComponent.anchors = anchors;
}
}
}
private calculateCurrentTime(minTime: number, maxTime: number): number {
if (minTime !== this.minTime || maxTime !== this.maxTime) {
if (this.minTime >= this.currentTime || isUndefined(this.currentTime)) {
return this.minTime;
} else if (this.maxTime <= this.currentTime) {
return this.maxTime;
} else {
return this.minTime + Math.ceil((this.currentTime - this.minTime) / this.settings.tripTimeline.timeStep) * this.settings.tripTimeline.timeStep;
}
}
return this.currentTime;
}
private resize() {
@ -793,6 +838,9 @@ export abstract class TbMap<S extends BaseMapSettings> {
private updateBounds() {
const enabledDataLayers = this.dataLayers.filter(dl => dl.isEnabled());
const dataLayersBounds = enabledDataLayers.map(dl => dl.getBounds()).filter(b => b.isValid());
const enabledTripsDataLayers = this.tripDataLayers.filter(dl => dl.isEnabled());
const tripsDataLayersBounds = enabledTripsDataLayers.map(dl => dl.getBounds()).filter(b => b.isValid());
dataLayersBounds.push(...tripsDataLayersBounds);
let bounds: L.LatLngBounds;
if (dataLayersBounds.length) {
bounds = new L.LatLngBounds(null, null);
@ -902,7 +950,7 @@ export abstract class TbMap<S extends BaseMapSettings> {
public enabledDataLayersUpdated() {
this.updateAddButtonsStates();
this.updateTripsTimeline();
this.updateTripsAnchors();
}
public dataItemClick($event: Event, action: WidgetAction, entityInfo: TbMapDatasource) {
@ -993,6 +1041,28 @@ export abstract class TbMap<S extends BaseMapSettings> {
}
}
// Timeline methods
public hasTimeline(): boolean {
return this.timeline;
}
public getMinTime(): number {
return this.minTime;
}
public getMaxTime(): number {
return this.maxTime;
}
public getTimeStep(): number {
return this.timeStep;
}
public getCurrentTime(): number {
return this.currentTime;
}
public destroy() {
if (this.mapResize$) {
this.mapResize$.disconnect();

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

@ -23,7 +23,7 @@ import {
WidgetActionType
} from '@shared/models/widget.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { guid, hashCode, isDefinedAndNotNull, isNotEmptyStr, isString, mergeDeep } from '@core/utils';
import { guid, hashCode, isDefinedAndNotNull, isNotEmptyStr, isString, isUndefined, mergeDeep } from '@core/utils';
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
import { materialColors } from '@shared/models/material.models';
import L from 'leaflet';
@ -414,6 +414,7 @@ export const defaultBaseTripsDataLayerSettings = (mapType: MapType): Partial<Tri
defaultBaseMarkersDataLayerSettings(mapType),
{
tooltip: {
offsetY: -0.5,
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}',
@ -1274,7 +1275,7 @@ export const processTooltipTemplate = (template: string): string => {
return template;
}
export function calculateNewPointCoordinate(coordinate: number, imageSize: number): number {
export const calculateNewPointCoordinate = (coordinate: number, imageSize: number): number => {
let pointCoordinate = coordinate / imageSize;
if (pointCoordinate < 0) {
pointCoordinate = 0;
@ -1284,7 +1285,7 @@ export function calculateNewPointCoordinate(coordinate: number, imageSize: numbe
return pointCoordinate;
}
export function checkLngLat(point: L.LatLng, southWest: L.LatLng, northEast: L.LatLng, offset = 0): L.LatLng {
export const checkLngLat = (point: L.LatLng, southWest: L.LatLng, northEast: L.LatLng, offset = 0): L.LatLng => {
const maxLngMap = northEast.lng - offset;
const minLngMap = southWest.lng + offset;
const maxLatMap = northEast.lat - offset;
@ -1301,3 +1302,48 @@ export function checkLngLat(point: L.LatLng, southWest: L.LatLng, northEast: L.L
}
return point;
}
export type TripRouteData = {[time: number]: FormattedData<TbMapDatasource>};
export const calculateInterpolationRatio = (firsMoment: number, secondMoment: number, intermediateMoment: number): number => {
return (intermediateMoment - firsMoment) / (secondMoment - firsMoment);
}
export const interpolateLineSegment = (
pointA: FormattedData,
pointB: FormattedData,
xKey: string,
yKey: string,
ratio: number
): { [key: string]: number } => {
return {
[xKey]: (pointA[xKey] + (pointB[xKey] - pointA[xKey]) * ratio),
[yKey]: (pointA[yKey] + (pointB[yKey] - pointA[yKey]) * ratio)
};
}
export const findRotationAngle = (startPoint: FormattedData, endPoint: FormattedData, xKey: string, yKey: string): number => {
if (isUndefined(startPoint) || isUndefined(endPoint)) {
return 0;
}
let angle = -Math.atan2(endPoint[xKey] - startPoint[xKey], endPoint[yKey] - startPoint[yKey]);
angle = angle * 180 / Math.PI;
return parseInt(angle.toFixed(2), 10);
}
export const calculateLastPoints = (routeData: TripRouteData, time: number): FormattedData<TbMapDatasource> => {
const timeArr = Object.keys(routeData);
let index = timeArr.findIndex((dtime) => {
return Number(dtime) >= time;
});
if (index !== -1) {
if (Number(timeArr[index]) !== time && index !== 0) {
index--;
}
} else {
index = timeArr.length - 1;
}
return routeData[timeArr[index]];
}

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

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

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

@ -14,14 +14,37 @@
* limitations under the License.
*/
.tb-map-timeline-panel {
padding-top: 4px;
padding-left: 16px;
padding-right: 16px;
height: 100px;
padding: 4px 16px 8px;
display: flex;
flex-direction: column;
.tb-timeline-controls {
display: flex;
flex-direction: row;
align-items: center;
.tb-map-timeline-timestamp {
font-size: 14px;
font-weight: 400;
color: rgba(0,0,0,0.76);
}
.tb-timeline-buttons {
.mat-mdc-mini-fab.mat-primary {
--mdc-fab-small-container-color: rgba(0,0,0,0);
--mat-fab-small-foreground-color: rgba(0,0,0,0.54);
--mdc-fab-small-container-elevation-shadow: 4px 4px 4px 0px rgba(0, 0, 0, 0.15);
&:before {
opacity: 0.1;
}
}
.tb-timeline-button-32 {
width: 32px;
height: 32px;
}
}
}
.tb-map-timeline-no-data {
font-size: 18px;
font-weight: 400;
color: rgba(0,0,0,0.76);
}
}

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

@ -16,17 +16,20 @@
import {
ChangeDetectorRef,
Component,
Component, DestroyRef,
ElementRef,
EventEmitter, Injector,
Input, OnChanges,
OnDestroy,
EventEmitter,
Injector,
Input,
OnInit,
Output,
ViewEncapsulation
} from '@angular/core';
import { TripTimelineSettings } from '@home/components/widget/lib/maps/models/map.models';
import { DateFormatProcessor } from '@shared/models/widget-settings.models';
import { interval, Observable, Subscription } from 'rxjs';
import { filter } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'tb-map-timeline-panel',
@ -34,7 +37,7 @@ import { DateFormatProcessor } from '@shared/models/widget-settings.models';
styleUrls: ['./map-timeline-panel.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class MapTimelinePanelComponent implements OnInit, OnChanges, OnDestroy {
export class MapTimelinePanelComponent implements OnInit {
@Input()
settings: TripTimelineSettings;
@ -43,22 +46,70 @@ export class MapTimelinePanelComponent implements OnInit, OnChanges, OnDestroy {
disabled = false;
@Input()
min = 0;
set min(value: number) {
if (this.minValue !== value) {
this.minValue = value;
this.maxTimeIndex = Math.ceil((this.maxValue - this.minValue) / this.settings.timeStep);
this.cd.markForCheck();
}
}
get min(): number {
return this.minValue;
}
@Input()
set max(value: number) {
if (this.maxValue !== value) {
this.maxValue = value;
this.maxTimeIndex = Math.ceil((this.maxValue - this.minValue) / this.settings.timeStep);
this.cd.markForCheck();
}
}
get max(): number {
return this.maxValue;
}
@Input()
max = 10000;
set currentTime(time: number) {
if (this.currentTimeValue !== time) {
this.currentTimeValue = time;
this.updateTimestampDisplayValue();
this.cd.markForCheck();
}
}
get currentTime(): number {
return this.currentTimeValue;
}
get hasData(): boolean {
return !!this.currentTimeValue && this.currentTimeValue !== Infinity;
}
@Input()
anchors: number[] = [];
@Output()
timeChanged = new EventEmitter<number>();
currentTime = 0;
timestampFormat: DateFormatProcessor;
minTimeIndex = 0;
maxTimeIndex = 0;
index = 0;
playing = false;
interval: Subscription;
speed: number;
private minValue: number;
private maxValue: number;
private currentTimeValue: number = null;
constructor(public element: ElementRef<HTMLElement>,
private cd: ChangeDetectorRef,
private destroyRef: DestroyRef,
private injector: Injector) {
}
@ -70,25 +121,110 @@ export class MapTimelinePanelComponent implements OnInit, OnChanges, OnDestroy {
this.speed = this.settings.speedOptions[0];
}
ngOnChanges() {
this.currentTime = this.min === Infinity ? 0 : this.min;
if (this.settings.showTimestamp) {
this.timestampFormat.update(this.currentTime);
this.cd.markForCheck();
public onIndexChange(index: number) {
this.index = index;
this.updateCurrentTime();
}
public play() {
this.playing = true;
if (!this.interval) {
this.interval = interval(1000 / this.speed)
.pipe(
takeUntilDestroyed(this.destroyRef),
filter(() => this.playing)
).subscribe(
{
next: () => {
if (this.index < this.maxTimeIndex) {
this.index++;
this.updateCurrentTime();
} else {
this.playing = false;
this.cd.markForCheck();
this.interval.unsubscribe();
this.interval = null;
}
},
error: (err) => {
console.error(err);
}
}
);
}
}
ngOnDestroy() {
public pause() {
this.playing = false;
this.updateCurrentTime();
}
public onTimeChange() {
if (this.settings.showTimestamp) {
public fastRewind() {
this.index = this.minTimeIndex;
this.pause();
}
public fastForward() {
this.index = this.maxTimeIndex;
this.pause();
}
public moveNext() {
if (this.index < this.maxTimeIndex) {
if (this.settings.snapToRealLocation) {
const anchorIndex = this.findIndex(this.currentTime, this.anchors) + 1;
this.index = Math.floor((this.anchors[anchorIndex] - this.minValue) / this.settings.timeStep);
} else {
this.index++;
}
}
this.pause();
}
public movePrev() {
if (this.index > this.minTimeIndex) {
if (this.settings.snapToRealLocation) {
const anchorIndex = this.findIndex(this.currentTime, this.anchors) - 1;
this.index = Math.floor((this.anchors[anchorIndex] - this.minValue) / this.settings.timeStep);
} else {
this.index--;
}
}
this.pause();
}
public speedUpdated() {
if (this.interval) {
this.interval.unsubscribe();
this.interval = null;
}
if (this.playing) {
this.play();
}
}
private updateCurrentTime() {
const newTime = this.minValue + this.index * this.settings.timeStep;
if (this.currentTime !== newTime) {
this.currentTime = newTime;
this.timeChanged.emit(this.currentTime);
this.updateTimestampDisplayValue();
}
}
private updateTimestampDisplayValue() {
if (this.settings.showTimestamp && this.hasData) {
this.timestampFormat.update(this.currentTime);
this.cd.markForCheck();
}
this.timeChanged.next(this.currentTime);
}
public speedUpdated() {}
private findIndex(value: number, array: number[]): number {
let i = 0;
while (array[i] < value) {
i++;
}
return i;
}
}

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

@ -7712,7 +7712,8 @@
"speed-options": "Speed options",
"timestamp": "Timestamp",
"snap-to-real-location": "Snap to real location",
"location-snap-filter-function": "Location snap filter function"
"location-snap-filter-function": "Location snap filter function",
"no-trips-data-available": "No trips data available"
},
"map-action": {
"map-action-buttons": "Map action buttons",

Loading…
Cancel
Save