From cd3a7c4c4ea1e1ff863a7006d24a6184285535d9 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 28 Feb 2025 19:31:22 +0200 Subject: [PATCH] UI: Trip Map - Implement timeline control. Implement trip item. --- .../lib/maps/data-layer/data-layer-utils.ts | 96 +++++ .../lib/maps/data-layer/map-data-layer.ts | 67 +--- .../lib/maps/data-layer/trips-data-layer.ts | 347 +++++++++++++++++- .../home/components/widget/lib/maps/map.ts | 106 +++++- .../widget/lib/maps/models/map.models.ts | 52 ++- .../panels/map-timeline-panel.component.html | 74 +++- .../panels/map-timeline-panel.component.scss | 29 +- .../panels/map-timeline-panel.component.ts | 174 ++++++++- .../assets/locale/locale.constant-en_US.json | 3 +- 9 files changed, 819 insertions(+), 129 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts new file mode 100644 index 0000000000..f5872844c9 --- /dev/null +++ b/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, + layer: L.Layer, + settings: DataLayerTooltipSettings, + data: FormattedData, + 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, + tooltip: L.Popup, + settings: DataLayerTooltipSettings, + processor: DataLayerPatternProcessor, + data: FormattedData, + dsData: FormattedData[]): 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, tooltip: L.Popup, settings: DataLayerTooltipSettings, data: FormattedData): 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; + }; + } + } + } + ); +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts index 8916931d2b..1c4e58a320 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts +++ b/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 = TbMapDataLayer, L extends L.Layer = L.Layer> { @@ -60,8 +61,12 @@ export abstract class TbDataLayerItem { + 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, dsData: FormattedData[]) { 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; - 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 { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts index 63824365ca..a7b0d74d4d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts +++ b/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}; + +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; + + constructor(private rawRouteData: FormattedData[], + private latestData: FormattedData, + 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[]) { + 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) { + 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) { + 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) { + 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, dsData: FormattedData[]) { + const location = this.dataLayer.extractLocation(data, dsData); + if (!this.marker.getLatLng().equals(location)) { + this.marker.setLatLng(location); + } + } + + private updateMarkerIcon(data: FormattedData, dsData: FormattedData[]) { + 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, dsData: FormattedData[]) { + 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 = `
${label}
`; + 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 { @@ -205,7 +445,7 @@ class ImageTripMarkerIconProcessor extends TripMarkerIconProcessor`, className: 'tb-marker-div-icon', @@ -241,6 +481,8 @@ export class TbTripsDataLayer extends TbMapDataLayer(); + private positionFunction: CompiledTbFunction; private rawTripsData: FormattedData[][]; @@ -338,16 +580,66 @@ export class TbTripsDataLayer extends TbMapDataLayer { + 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[]) { 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[]): FormattedData[] { @@ -413,4 +705,39 @@ export class TbTripsDataLayer extends TbMapDataLayer): {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, dsData: FormattedData[]): 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; + } + } + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index 7e884c473d..11af4338e9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/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 { protected tripDataLayers: TbTripsDataLayer[]; protected dsData: FormattedData[]; + 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 { protected timeLineComponentRef: ComponentRef; protected timeLineComponent: MapTimelinePanelComponent; - protected addMarkerDataLayers: TbMapDataLayer[]; - protected addPolygonDataLayers: TbMapDataLayer[]; - protected addCircleDataLayers: TbMapDataLayer[]; + protected addMarkerDataLayers: TbMapDataLayer[]; + protected addPolygonDataLayers: TbMapDataLayer[]; + protected addCircleDataLayers: TbMapDataLayer[]; private readonly mapResize$: ResizeObserver; @@ -135,11 +141,14 @@ export abstract class TbMap { $(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 { 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 { 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 { const tripsLatestData = formattedDataFormDatasourceData(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 { 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 { public enabledDataLayersUpdated() { this.updateAddButtonsStates(); - this.updateTripsTimeline(); + this.updateTripsAnchors(); } public dataItemClick($event: Event, action: WidgetAction, entityInfo: TbMapDatasource) { @@ -993,6 +1041,28 @@ export abstract class TbMap { } } + // 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(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts index f93eab6dbc..79fb8a7c04 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts +++ b/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${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
End Time: ${maxTime}
Start Time: ${minTime}' : '${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
End Time: ${maxTime}
Start Time: ${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}; + +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 => { + 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]]; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html index 57bf909613..7a725e48eb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html @@ -16,22 +16,62 @@ -->
- - - -
-
-
+ @if(hasData) { + + + +
+
+
+
+
+ + + @if(playing) { + + } @else { + + } + + +
+
+ + {{speed}}x + {{speedValue}}x + +
-
2
-
- - {{speedValue}}x - -
-
+ } @else { +
widgets.maps.timeline.no-trips-data-available
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss index 7795e05523..3a022f65f3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss +++ b/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); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts index 1cb76aceea..7b2b428c5c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts +++ b/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(); - 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, 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; + } } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 39156e001b..c0876889f3 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/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",